sello 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.
Files changed (47) hide show
  1. package/LICENSE +200 -0
  2. package/README.md +195 -0
  3. package/SPEC.md +738 -0
  4. package/docs/assets/sello-banner.png +0 -0
  5. package/docs/assets/sello-social-preview.png +0 -0
  6. package/docs/decisions.md +79 -0
  7. package/docs/paper/notarized-agents.md +523 -0
  8. package/docs/paper/notarized-agents.pdf +0 -0
  9. package/docs/paper/notarized-agents.tex +1387 -0
  10. package/docs/paper/refs.bib +245 -0
  11. package/docs/performance.md +24 -0
  12. package/docs/release-checklist.md +56 -0
  13. package/docs/sdk-build-plan.md +214 -0
  14. package/docs/sdk-quickstart.md +115 -0
  15. package/docs/sdk-security-audit.md +53 -0
  16. package/docs/security-review.md +54 -0
  17. package/examples/mcp-tool-server.ts +250 -0
  18. package/examples/quickstart-tool.ts +178 -0
  19. package/fixtures/vectors/.gitkeep +1 -0
  20. package/fixtures/vectors/sello-v0.1.json +101 -0
  21. package/package.json +52 -0
  22. package/src/cbor.ts +337 -0
  23. package/src/cli/bench.ts +390 -0
  24. package/src/cli/demo.ts +114 -0
  25. package/src/cli/sello.ts +514 -0
  26. package/src/cose/protected-header.ts +210 -0
  27. package/src/cose/sign1.ts +124 -0
  28. package/src/crypto/ed25519.ts +117 -0
  29. package/src/crypto/identifiers.ts +64 -0
  30. package/src/hpke/base.ts +349 -0
  31. package/src/hpke/receipt.ts +79 -0
  32. package/src/index.ts +15 -0
  33. package/src/log/canonical-url.ts +168 -0
  34. package/src/log/mock-log.ts +170 -0
  35. package/src/log/rekor.ts +147 -0
  36. package/src/log/types.ts +27 -0
  37. package/src/mcp/middleware.ts +198 -0
  38. package/src/owner/verify.ts +276 -0
  39. package/src/receipt/body.ts +210 -0
  40. package/src/registry/json-registry.ts +233 -0
  41. package/src/sdk/index.ts +22 -0
  42. package/src/sdk/keys.ts +191 -0
  43. package/src/sdk/logs.ts +200 -0
  44. package/src/sdk/publisher.ts +145 -0
  45. package/src/sdk/service.ts +562 -0
  46. package/src/service/create-receipt.ts +178 -0
  47. package/src/token/jws-profile.ts +174 -0
@@ -0,0 +1,178 @@
1
+ import { encodeProtectedHeader } from "../cose/protected-header.ts";
2
+ import { signReceiptEnvelope } from "../cose/sign1.ts";
3
+ import { deriveTokenIdentifiers, sha256 } from "../crypto/identifiers.ts";
4
+ import { sealReceiptBody } from "../hpke/receipt.ts";
5
+ import {
6
+ type CanonicalLogUrl,
7
+ assertCanonicalLogUrl,
8
+ logUrlsEqual,
9
+ } from "../log/canonical-url.ts";
10
+ import {
11
+ type ReceiptSubmissionLog,
12
+ type TransparencyLogEntry,
13
+ } from "../log/types.ts";
14
+ import {
15
+ ZERO_SHA256_DIGEST,
16
+ encodeReceiptBody,
17
+ type ReceiptBody,
18
+ type ResultStatus,
19
+ } from "../receipt/body.ts";
20
+ import { verifySelloJwsToken } from "../token/jws-profile.ts";
21
+
22
+ export type CreateReceiptInput = {
23
+ authorizationTokenBytes: Uint8Array;
24
+ ownerHpkePublicKey: Uint8Array;
25
+ selloLogs: readonly string[];
26
+ serviceKid: Uint8Array;
27
+ servicePrivateKey: Uint8Array;
28
+ serviceIdentifier: string;
29
+ log: ReceiptSubmissionLog;
30
+ actionType: string;
31
+ actionInputBytes: Uint8Array;
32
+ actionOutputBytes?: Uint8Array;
33
+ resultStatus: ResultStatus;
34
+ timestamp: string;
35
+ };
36
+
37
+ export type BuildReceiptInput = Omit<CreateReceiptInput, "log"> & {
38
+ logUrl: CanonicalLogUrl;
39
+ };
40
+
41
+ export type BuiltReceipt = {
42
+ receiptBody: ReceiptBody;
43
+ protectedHeaderBytes: Uint8Array;
44
+ envelope: Uint8Array;
45
+ };
46
+
47
+ export type CreatedReceipt = BuiltReceipt & {
48
+ logEntry: TransparencyLogEntry;
49
+ };
50
+
51
+ export type CreateReceiptFromJwsInput = Omit<
52
+ CreateReceiptInput,
53
+ "authorizationTokenBytes" | "ownerHpkePublicKey" | "selloLogs"
54
+ > & {
55
+ authorizationToken: string | Uint8Array;
56
+ tokenIssuerPublicKey: Uint8Array;
57
+ fallbackSelloLogs?: readonly string[];
58
+ };
59
+
60
+ export function buildReceipt(input: BuildReceiptInput): BuiltReceipt {
61
+ assertBytes(input.authorizationTokenBytes, "authorizationTokenBytes");
62
+ assertByteLength(input.ownerHpkePublicKey, 32, "ownerHpkePublicKey");
63
+ assertBytes(input.serviceKid, "serviceKid");
64
+ assertBytes(input.servicePrivateKey, "servicePrivateKey");
65
+ assertBytes(input.actionInputBytes, "actionInputBytes");
66
+
67
+ if (typeof input.serviceIdentifier !== "string" || input.serviceIdentifier.length === 0) {
68
+ throw new TypeError("serviceIdentifier must be a non-empty string");
69
+ }
70
+
71
+ if (typeof input.actionType !== "string" || input.actionType.length === 0) {
72
+ throw new TypeError("actionType must be a non-empty string");
73
+ }
74
+
75
+ const selectedLogUrl = selectOwnerTrustedLog(input.selloLogs, input.logUrl);
76
+ const identifiers = deriveTokenIdentifiers(input.authorizationTokenBytes);
77
+ const receiptBody: ReceiptBody = {
78
+ "agent-identifier": identifiers.agent_identifier,
79
+ "action-type": input.actionType,
80
+ "action-input-hash": sha256(input.actionInputBytes),
81
+ "action-output-hash":
82
+ input.resultStatus === "denied"
83
+ ? ZERO_SHA256_DIGEST
84
+ : sha256(input.actionOutputBytes ?? new Uint8Array()),
85
+ "result-status": input.resultStatus,
86
+ timestamp: input.timestamp,
87
+ };
88
+ const protectedHeaderBytes = encodeProtectedHeader({
89
+ kid: input.serviceKid,
90
+ sello_token_ref: identifiers.sello_token_ref,
91
+ sello_log_url: selectedLogUrl,
92
+ });
93
+ const payload = sealReceiptBody({
94
+ plaintext: encodeReceiptBody(receiptBody),
95
+ ownerPublicKey: input.ownerHpkePublicKey,
96
+ protectedHeaderBytes,
97
+ serviceIdentifier: input.serviceIdentifier,
98
+ selloTokenRef: identifiers.sello_token_ref,
99
+ });
100
+ const envelope = signReceiptEnvelope({
101
+ protectedHeaderBytes,
102
+ payload,
103
+ servicePrivateKey: input.servicePrivateKey,
104
+ });
105
+
106
+ return {
107
+ receiptBody,
108
+ protectedHeaderBytes,
109
+ envelope,
110
+ };
111
+ }
112
+
113
+ export function createReceipt(input: CreateReceiptInput): CreatedReceipt {
114
+ const built = buildReceipt({
115
+ ...input,
116
+ logUrl: input.log.logUrl,
117
+ });
118
+ const logEntry = input.log.append(built.envelope, input.timestamp);
119
+
120
+ return {
121
+ ...built,
122
+ logEntry,
123
+ };
124
+ }
125
+
126
+ export function createReceiptFromJwsToken(
127
+ input: CreateReceiptFromJwsInput,
128
+ ): CreatedReceipt {
129
+ const verifiedToken = verifySelloJwsToken({
130
+ authorizationToken: input.authorizationToken,
131
+ issuerPublicKey: input.tokenIssuerPublicKey,
132
+ });
133
+ const selloLogs = verifiedToken.selloLogs ?? input.fallbackSelloLogs;
134
+
135
+ return createReceipt({
136
+ ...input,
137
+ authorizationTokenBytes: verifiedToken.authorizationTokenBytes,
138
+ ownerHpkePublicKey: verifiedToken.ownerHpkePublicKey,
139
+ selloLogs: selloLogs ?? [],
140
+ });
141
+ }
142
+
143
+ function selectOwnerTrustedLog(
144
+ selloLogs: readonly string[],
145
+ candidateLogUrl: CanonicalLogUrl,
146
+ ): CanonicalLogUrl {
147
+ if (!Array.isArray(selloLogs) || selloLogs.length === 0) {
148
+ throw new TypeError("selloLogs must contain at least one owner-trusted log");
149
+ }
150
+
151
+ const canonicalLogs = selloLogs.map((logUrl) => {
152
+ assertCanonicalLogUrl(logUrl, "selloLogs entry");
153
+ return logUrl;
154
+ });
155
+
156
+ const match = canonicalLogs.find((logUrl) => logUrlsEqual(logUrl, candidateLogUrl));
157
+ if (!match) {
158
+ throw new TypeError("service log must be listed in selloLogs");
159
+ }
160
+
161
+ return match;
162
+ }
163
+
164
+ function assertByteLength(
165
+ value: unknown,
166
+ length: number,
167
+ name: string,
168
+ ): asserts value is Uint8Array {
169
+ if (!(value instanceof Uint8Array) || value.byteLength !== length) {
170
+ throw new TypeError(`${name} must be a ${length}-byte Uint8Array`);
171
+ }
172
+ }
173
+
174
+ function assertBytes(value: unknown, name: string): asserts value is Uint8Array {
175
+ if (!(value instanceof Uint8Array)) {
176
+ throw new TypeError(`${name} must be a Uint8Array`);
177
+ }
178
+ }
@@ -0,0 +1,174 @@
1
+ import { signEd25519, verifyEd25519Signature } from "../crypto/ed25519.ts";
2
+ import { assertCanonicalLogUrl } from "../log/canonical-url.ts";
3
+
4
+ export type VerifiedSelloJwsToken = {
5
+ authorizationTokenBytes: Uint8Array;
6
+ ownerHpkePublicKey: Uint8Array;
7
+ selloLogs?: readonly string[];
8
+ protectedHeader: Record<string, unknown>;
9
+ payload: Record<string, unknown>;
10
+ };
11
+
12
+ export type VerifySelloJwsTokenInput = {
13
+ authorizationToken: string | Uint8Array;
14
+ issuerPublicKey: Uint8Array;
15
+ };
16
+
17
+ export type SignSelloJwsTokenInput = {
18
+ payload: Record<string, unknown>;
19
+ issuerPrivateKey: Uint8Array;
20
+ protectedHeader?: Record<string, unknown>;
21
+ };
22
+
23
+ const textEncoder = new TextEncoder();
24
+ const textDecoder = new TextDecoder("utf-8", { fatal: true });
25
+ const BASE64URL_32_BYTE_LENGTH = 43;
26
+
27
+ export function verifySelloJwsToken(
28
+ input: VerifySelloJwsTokenInput,
29
+ ): VerifiedSelloJwsToken {
30
+ const authorizationTokenBytes = normalizeTokenBytes(input.authorizationToken);
31
+ const authorizationToken = textDecoder.decode(authorizationTokenBytes);
32
+ const parts = authorizationToken.split(".");
33
+
34
+ if (parts.length !== 3 || parts.some((part) => part.length === 0)) {
35
+ throw new TypeError("authorization token must be compact JWS");
36
+ }
37
+
38
+ const [encodedProtected, encodedPayload, encodedSignature] = parts;
39
+ const protectedHeader = parseJsonObject(
40
+ base64urlDecode(encodedProtected, "JWS protected header"),
41
+ "JWS protected header",
42
+ );
43
+
44
+ if (protectedHeader.alg !== "EdDSA") {
45
+ throw new TypeError("JWS alg must be EdDSA");
46
+ }
47
+
48
+ if ("crit" in protectedHeader) {
49
+ throw new TypeError("JWS crit is not supported by the v0.1 token profile");
50
+ }
51
+
52
+ const signingInput = textEncoder.encode(`${encodedProtected}.${encodedPayload}`);
53
+ const signature = base64urlDecode(encodedSignature, "JWS signature");
54
+ if (!verifyEd25519Signature(signingInput, signature, input.issuerPublicKey)) {
55
+ throw new TypeError("JWS signature verification failed");
56
+ }
57
+
58
+ const payload = parseJsonObject(
59
+ base64urlDecode(encodedPayload, "JWS payload"),
60
+ "JWS payload",
61
+ );
62
+ const ownerHpkePublicKey = readOwnerHpkePublicKey(payload);
63
+ const selloLogs = readSelloLogs(payload);
64
+
65
+ return {
66
+ authorizationTokenBytes,
67
+ ownerHpkePublicKey,
68
+ ...(selloLogs === undefined ? {} : { selloLogs }),
69
+ protectedHeader,
70
+ payload,
71
+ };
72
+ }
73
+
74
+ export function signSelloJwsToken(input: SignSelloJwsTokenInput): string {
75
+ const protectedHeader = {
76
+ alg: "EdDSA",
77
+ typ: "JWT",
78
+ ...input.protectedHeader,
79
+ };
80
+ const encodedProtected = base64urlEncode(
81
+ textEncoder.encode(JSON.stringify(protectedHeader)),
82
+ );
83
+ const encodedPayload = base64urlEncode(
84
+ textEncoder.encode(JSON.stringify(input.payload)),
85
+ );
86
+ const signingInput = textEncoder.encode(`${encodedProtected}.${encodedPayload}`);
87
+ const signature = signEd25519(signingInput, input.issuerPrivateKey);
88
+
89
+ return `${encodedProtected}.${encodedPayload}.${base64urlEncode(signature)}`;
90
+ }
91
+
92
+ export function base64urlEncode(bytes: Uint8Array): string {
93
+ return Buffer.from(bytes).toString("base64url");
94
+ }
95
+
96
+ function readOwnerHpkePublicKey(payload: Record<string, unknown>): Uint8Array {
97
+ const encoded = payload.owner_hpke_pk;
98
+
99
+ if (typeof encoded !== "string") {
100
+ throw new TypeError("owner_hpke_pk must be a string");
101
+ }
102
+
103
+ if (encoded.length !== BASE64URL_32_BYTE_LENGTH) {
104
+ throw new TypeError("owner_hpke_pk must encode a raw 32-byte X25519 public key");
105
+ }
106
+
107
+ const publicKey = base64urlDecode(encoded, "owner_hpke_pk");
108
+ if (publicKey.byteLength !== 32) {
109
+ throw new TypeError("owner_hpke_pk must encode a raw 32-byte X25519 public key");
110
+ }
111
+
112
+ return publicKey;
113
+ }
114
+
115
+ function readSelloLogs(payload: Record<string, unknown>): readonly string[] | undefined {
116
+ const value = payload.sello_logs;
117
+
118
+ if (value === undefined) {
119
+ return undefined;
120
+ }
121
+
122
+ if (!Array.isArray(value)) {
123
+ throw new TypeError("sello_logs must be an array");
124
+ }
125
+
126
+ return value.map((entry) => {
127
+ if (typeof entry !== "string") {
128
+ throw new TypeError("sello_logs entries must be strings");
129
+ }
130
+
131
+ assertCanonicalLogUrl(entry, "sello_logs entry");
132
+ return entry;
133
+ });
134
+ }
135
+
136
+ function normalizeTokenBytes(token: string | Uint8Array): Uint8Array {
137
+ if (typeof token === "string") {
138
+ if (!/^[\x21-\x7e]+$/.test(token)) {
139
+ throw new TypeError("authorization token must be visible ASCII");
140
+ }
141
+
142
+ return textEncoder.encode(token);
143
+ }
144
+
145
+ if (token instanceof Uint8Array) {
146
+ return new Uint8Array(token);
147
+ }
148
+
149
+ throw new TypeError("authorizationToken must be a string or Uint8Array");
150
+ }
151
+
152
+ function parseJsonObject(bytes: Uint8Array, name: string): Record<string, unknown> {
153
+ let parsed: unknown;
154
+
155
+ try {
156
+ parsed = JSON.parse(textDecoder.decode(bytes));
157
+ } catch {
158
+ throw new TypeError(`${name} must be UTF-8 JSON`);
159
+ }
160
+
161
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
162
+ throw new TypeError(`${name} must be a JSON object`);
163
+ }
164
+
165
+ return parsed as Record<string, unknown>;
166
+ }
167
+
168
+ function base64urlDecode(value: string, name: string): Uint8Array {
169
+ if (!/^[A-Za-z0-9_-]*$/.test(value) || value.length % 4 === 1) {
170
+ throw new TypeError(`${name} must be unpadded base64url`);
171
+ }
172
+
173
+ return Uint8Array.from(Buffer.from(value, "base64url"));
174
+ }