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,22 @@
1
+ import { createSelloService } from "./service.ts";
2
+ import * as logs from "./logs.ts";
3
+
4
+ export const sello = {
5
+ service: createSelloService,
6
+ logs,
7
+ };
8
+
9
+ export { createSelloService };
10
+ export {
11
+ decodeBase64url,
12
+ encodeOwnerKey,
13
+ encodeServiceKey,
14
+ normalizeEd25519PrivateKey,
15
+ normalizeEd25519PublicKey,
16
+ normalizeHpkePrivateKey,
17
+ normalizeKid,
18
+ normalizeServiceKey,
19
+ } from "./keys.ts";
20
+ export * from "./logs.ts";
21
+ export * from "./publisher.ts";
22
+ export * from "./service.ts";
@@ -0,0 +1,191 @@
1
+ import {
2
+ assertEd25519PrivateKey,
3
+ assertEd25519PublicKey,
4
+ } from "../crypto/ed25519.ts";
5
+
6
+ export type KeyInput = string | Uint8Array;
7
+
8
+ export type ServiceKeyInput =
9
+ | string
10
+ | {
11
+ kid: KeyInput;
12
+ privateKey: KeyInput;
13
+ };
14
+
15
+ export type NormalizedServiceKey = {
16
+ kid: Uint8Array;
17
+ privateKey: Uint8Array;
18
+ };
19
+
20
+ const textEncoder = new TextEncoder();
21
+
22
+ export function normalizeServiceKey(
23
+ input: ServiceKeyInput | undefined,
24
+ fallbackKid?: KeyInput,
25
+ ): NormalizedServiceKey {
26
+ if (input === undefined) {
27
+ throw new TypeError("SELLO_SERVICE_KEY is required");
28
+ }
29
+
30
+ if (typeof input === "object" && !(input instanceof Uint8Array)) {
31
+ const kid = normalizeKid(input.kid, "serviceKey.kid");
32
+ const privateKey = normalizeEd25519PrivateKey(
33
+ input.privateKey,
34
+ "serviceKey.privateKey",
35
+ );
36
+ return { kid, privateKey };
37
+ }
38
+
39
+ if (typeof input !== "string") {
40
+ throw new TypeError("serviceKey must be a string or { kid, privateKey }");
41
+ }
42
+
43
+ const encoded = stripKnownServiceKeyPrefix(input);
44
+ const separator = encoded.indexOf(".");
45
+ if (separator !== -1) {
46
+ const kid = decodeBase64url(encoded.slice(0, separator), "service key kid");
47
+ const privateKey = normalizeEd25519PrivateKey(
48
+ encoded.slice(separator + 1),
49
+ "service key private key",
50
+ );
51
+ return { kid, privateKey };
52
+ }
53
+
54
+ if (fallbackKid === undefined) {
55
+ throw new TypeError(
56
+ "SELLO_SERVICE_KEY must include a kid, or SELLO_SERVICE_KID must be set",
57
+ );
58
+ }
59
+
60
+ return {
61
+ kid: normalizeKid(fallbackKid, "service kid"),
62
+ privateKey: normalizeEd25519PrivateKey(input, "service key private key"),
63
+ };
64
+ }
65
+
66
+ export function encodeServiceKey(
67
+ kid: Uint8Array,
68
+ privateKey: Uint8Array,
69
+ prefix = "sello_dev",
70
+ ): string {
71
+ assertBytes(kid, "kid");
72
+ assertEd25519PrivateKey(privateKey, "privateKey");
73
+ return `${prefix}_${base64urlEncode(kid)}.${base64urlEncode(privateKey)}`;
74
+ }
75
+
76
+ export function normalizeKid(input: KeyInput, name = "kid"): Uint8Array {
77
+ if (input instanceof Uint8Array) {
78
+ if (input.byteLength === 0) {
79
+ throw new TypeError(`${name} must not be empty`);
80
+ }
81
+ return new Uint8Array(input);
82
+ }
83
+
84
+ if (typeof input !== "string" || input.length === 0) {
85
+ throw new TypeError(`${name} must be a non-empty string or Uint8Array`);
86
+ }
87
+
88
+ return textEncoder.encode(input);
89
+ }
90
+
91
+ export function normalizeEd25519PrivateKey(
92
+ input: KeyInput,
93
+ name = "privateKey",
94
+ ): Uint8Array {
95
+ const key = normalizeFixedBase64urlKey(input, 32, name);
96
+ assertEd25519PrivateKey(key, name);
97
+ return key;
98
+ }
99
+
100
+ export function normalizeEd25519PublicKey(
101
+ input: KeyInput,
102
+ name = "publicKey",
103
+ ): Uint8Array {
104
+ const key = normalizeFixedBase64urlKey(input, 32, name);
105
+ assertEd25519PublicKey(key, name);
106
+ return key;
107
+ }
108
+
109
+ export function normalizeHpkePrivateKey(
110
+ input: KeyInput,
111
+ name = "ownerPrivateKey",
112
+ ): Uint8Array {
113
+ return normalizeFixedBase64urlKey(stripKnownOwnerKeyPrefix(input), 32, name);
114
+ }
115
+
116
+ export function encodeOwnerKey(privateKey: Uint8Array, prefix = "sello_owner_dev"): string {
117
+ assertByteLength(privateKey, 32, "privateKey");
118
+ return `${prefix}_${base64urlEncode(privateKey)}`;
119
+ }
120
+
121
+ export function base64urlEncode(bytes: Uint8Array): string {
122
+ assertBytes(bytes, "bytes");
123
+ return Buffer.from(bytes).toString("base64url");
124
+ }
125
+
126
+ export function decodeBase64url(value: string, name = "value"): Uint8Array {
127
+ if (!/^[A-Za-z0-9_-]+$/.test(value) || value.length % 4 === 1) {
128
+ throw new TypeError(`${name} must be unpadded base64url`);
129
+ }
130
+
131
+ return Uint8Array.from(Buffer.from(value, "base64url"));
132
+ }
133
+
134
+ function normalizeFixedBase64urlKey(
135
+ input: KeyInput,
136
+ length: number,
137
+ name: string,
138
+ ): Uint8Array {
139
+ if (input instanceof Uint8Array) {
140
+ assertByteLength(input, length, name);
141
+ return new Uint8Array(input);
142
+ }
143
+
144
+ if (typeof input !== "string") {
145
+ throw new TypeError(`${name} must be a string or Uint8Array`);
146
+ }
147
+
148
+ const decoded = decodeBase64url(input, name);
149
+ assertByteLength(decoded, length, name);
150
+ return decoded;
151
+ }
152
+
153
+ function stripKnownServiceKeyPrefix(input: string): string {
154
+ for (const prefix of ["sello_dev_", "sello_live_local_"]) {
155
+ if (input.startsWith(prefix)) {
156
+ return input.slice(prefix.length);
157
+ }
158
+ }
159
+
160
+ return input;
161
+ }
162
+
163
+ function stripKnownOwnerKeyPrefix(input: KeyInput): KeyInput {
164
+ if (typeof input !== "string") {
165
+ return input;
166
+ }
167
+
168
+ for (const prefix of ["sello_owner_dev_", "sello_owner_live_"]) {
169
+ if (input.startsWith(prefix)) {
170
+ return input.slice(prefix.length);
171
+ }
172
+ }
173
+
174
+ return input;
175
+ }
176
+
177
+ function assertByteLength(
178
+ value: unknown,
179
+ length: number,
180
+ name: string,
181
+ ): asserts value is Uint8Array {
182
+ if (!(value instanceof Uint8Array) || value.byteLength !== length) {
183
+ throw new TypeError(`${name} must be a ${length}-byte Uint8Array`);
184
+ }
185
+ }
186
+
187
+ function assertBytes(value: unknown, name: string): asserts value is Uint8Array {
188
+ if (!(value instanceof Uint8Array)) {
189
+ throw new TypeError(`${name} must be a Uint8Array`);
190
+ }
191
+ }
@@ -0,0 +1,200 @@
1
+ import { toHex } from "../crypto/identifiers.ts";
2
+ import {
3
+ type CanonicalLogUrl,
4
+ assertCanonicalLogUrl,
5
+ } from "../log/canonical-url.ts";
6
+ import { MockTransparencyLog } from "../log/mock-log.ts";
7
+ import {
8
+ type TransparencyLogEntry,
9
+ type TransparencyLogQueryResult,
10
+ } from "../log/types.ts";
11
+ import { base64urlEncode, decodeBase64url } from "./keys.ts";
12
+
13
+ export type MaybePromise<T> = T | Promise<T>;
14
+
15
+ export type SdkSubmissionLog = {
16
+ logUrl: CanonicalLogUrl;
17
+ append(
18
+ envelope: Uint8Array,
19
+ integratedTime?: string,
20
+ ): MaybePromise<TransparencyLogEntry>;
21
+ };
22
+
23
+ export type SelloHttpLogOptions = {
24
+ endpoint?: string;
25
+ logUrl?: string;
26
+ headers?: Record<string, string>;
27
+ fetch?: typeof fetch;
28
+ };
29
+
30
+ export type SerializedTransparencyLogEntry = {
31
+ logUrl: string;
32
+ index: number;
33
+ integratedTime: string;
34
+ envelope: string;
35
+ proof: unknown;
36
+ };
37
+
38
+ export function memory(url: string): MockTransparencyLog {
39
+ return new MockTransparencyLog(toCanonicalLogUrl(url));
40
+ }
41
+
42
+ export function http(url: string, options: SelloHttpLogOptions = {}): HttpSelloLog {
43
+ return new HttpSelloLog(url, options);
44
+ }
45
+
46
+ export async function queryHttpLogByTokenRef(
47
+ input: {
48
+ endpoint: string;
49
+ tokenRef: Uint8Array;
50
+ headers?: Record<string, string>;
51
+ fetch?: typeof fetch;
52
+ },
53
+ ): Promise<TransparencyLogQueryResult> {
54
+ const fetcher = input.fetch ?? fetch;
55
+ const url = buildUrl(input.endpoint, `/entries?sello_token_ref=${toHex(input.tokenRef)}`);
56
+ const response = await fetcher(url, {
57
+ method: "GET",
58
+ headers: input.headers,
59
+ });
60
+
61
+ if (!response.ok) {
62
+ throw new TypeError(`Sello log query failed with HTTP ${response.status}`);
63
+ }
64
+
65
+ const decoded = await response.json();
66
+ if (!isRecord(decoded) || !Array.isArray(decoded.entries)) {
67
+ throw new TypeError("Sello log query response must contain entries");
68
+ }
69
+
70
+ const completeness =
71
+ decoded.completeness === "complete" ? "complete" : "discovery-only";
72
+
73
+ return {
74
+ completeness,
75
+ entries: decoded.entries.map(deserializeEntry),
76
+ };
77
+ }
78
+
79
+ export class HttpSelloLog {
80
+ readonly logUrl: CanonicalLogUrl;
81
+ readonly endpoint: string;
82
+ readonly #headers?: Record<string, string>;
83
+ readonly #fetch: typeof fetch;
84
+
85
+ constructor(url: string, options: SelloHttpLogOptions = {}) {
86
+ this.logUrl = toCanonicalLogUrl(options.logUrl ?? url);
87
+ this.endpoint = normalizeEndpoint(options.endpoint ?? url);
88
+ this.#headers = options.headers;
89
+ this.#fetch = options.fetch ?? fetch;
90
+ }
91
+
92
+ async append(
93
+ envelope: Uint8Array,
94
+ integratedTime?: string,
95
+ ): Promise<TransparencyLogEntry> {
96
+ const response = await this.#fetch(buildUrl(this.endpoint, "/entries"), {
97
+ method: "POST",
98
+ headers: {
99
+ "content-type": "application/json",
100
+ ...this.#headers,
101
+ },
102
+ body: JSON.stringify({
103
+ logUrl: this.logUrl,
104
+ envelope: base64urlEncode(envelope),
105
+ ...(integratedTime === undefined ? {} : { integratedTime }),
106
+ }),
107
+ });
108
+
109
+ if (!response.ok) {
110
+ throw new TypeError(`Sello log append failed with HTTP ${response.status}`);
111
+ }
112
+
113
+ return deserializeEntry(await response.json());
114
+ }
115
+ }
116
+
117
+ export function serializeEntry(
118
+ entry: TransparencyLogEntry,
119
+ ): SerializedTransparencyLogEntry {
120
+ return {
121
+ logUrl: entry.logUrl,
122
+ index: entry.index,
123
+ integratedTime: entry.integratedTime,
124
+ envelope: base64urlEncode(entry.envelope),
125
+ proof: cloneJson(entry.proof),
126
+ };
127
+ }
128
+
129
+ export function deserializeEntry(input: unknown): TransparencyLogEntry {
130
+ if (!isRecord(input)) {
131
+ throw new TypeError("Sello log entry must be an object");
132
+ }
133
+
134
+ const { logUrl, index, integratedTime, envelope, proof } = input;
135
+ assertCanonicalLogUrl(logUrl, "entry.logUrl");
136
+
137
+ if (!Number.isSafeInteger(index) || index < 0) {
138
+ throw new TypeError("entry.index must be a non-negative safe integer");
139
+ }
140
+
141
+ if (
142
+ typeof integratedTime !== "string" ||
143
+ !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(integratedTime)
144
+ ) {
145
+ throw new TypeError("entry.integratedTime must be an RFC 3339 UTC timestamp");
146
+ }
147
+
148
+ if (typeof envelope !== "string") {
149
+ throw new TypeError("entry.envelope must be base64url");
150
+ }
151
+
152
+ return {
153
+ logUrl,
154
+ index,
155
+ integratedTime,
156
+ envelope: decodeBase64url(envelope, "entry.envelope"),
157
+ proof: cloneJson(proof),
158
+ };
159
+ }
160
+
161
+ export function toCanonicalLogUrl(url: string): CanonicalLogUrl {
162
+ if (typeof url !== "string" || url.length === 0) {
163
+ throw new TypeError("log URL must be a non-empty string");
164
+ }
165
+
166
+ const parsed = new URL(url);
167
+ const path = parsed.pathname === "/" ? "/api" : parsed.pathname;
168
+ const protocol =
169
+ parsed.protocol === "http:" && isLocalHost(parsed.hostname) ? "https:" : parsed.protocol;
170
+ const canonical = `${protocol}//${parsed.host}${path}`;
171
+
172
+ assertCanonicalLogUrl(canonical, "logUrl");
173
+ return canonical;
174
+ }
175
+
176
+ function normalizeEndpoint(url: string): string {
177
+ const parsed = new URL(url);
178
+ const path = parsed.pathname === "/" ? "/api" : parsed.pathname.replace(/\/$/, "");
179
+ return `${parsed.protocol}//${parsed.host}${path}`;
180
+ }
181
+
182
+ function buildUrl(endpoint: string, path: string): string {
183
+ return `${endpoint.replace(/\/$/, "")}${path}`;
184
+ }
185
+
186
+ function isLocalHost(hostname: string): boolean {
187
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
188
+ }
189
+
190
+ function isRecord(value: unknown): value is Record<string, unknown> {
191
+ return typeof value === "object" && value !== null && !Array.isArray(value);
192
+ }
193
+
194
+ function cloneJson(value: unknown): unknown {
195
+ if (value === null || typeof value !== "object") {
196
+ return value;
197
+ }
198
+
199
+ return globalThis.structuredClone(value);
200
+ }
@@ -0,0 +1,145 @@
1
+ import { type TransparencyLogEntry } from "../log/types.ts";
2
+ import { type SdkSubmissionLog } from "./logs.ts";
3
+
4
+ export type SubmitMode = "background" | "await";
5
+
6
+ export type SelloSubmitOptions = {
7
+ mode?: SubmitMode;
8
+ maxPending?: number;
9
+ concurrency?: number;
10
+ };
11
+
12
+ export type PublishJob = {
13
+ envelope: Uint8Array;
14
+ integratedTime: string;
15
+ };
16
+
17
+ export type PublishResult = {
18
+ status: "submitted";
19
+ entry: TransparencyLogEntry;
20
+ };
21
+
22
+ export type DropEvent = {
23
+ envelope: Uint8Array;
24
+ integratedTime: string;
25
+ reason: "queue_full";
26
+ };
27
+
28
+ export type PublisherInput = {
29
+ log: SdkSubmissionLog;
30
+ submit?: SubmitMode | SelloSubmitOptions;
31
+ onSubmitError?: (error: unknown) => void;
32
+ onDrop?: (event: DropEvent) => void;
33
+ };
34
+
35
+ type QueuedJob = PublishJob & {
36
+ resolve: (entry: TransparencyLogEntry | undefined) => void;
37
+ reject: (error: unknown) => void;
38
+ };
39
+
40
+ export class BackgroundReceiptPublisher {
41
+ readonly #log: SdkSubmissionLog;
42
+ readonly #mode: SubmitMode;
43
+ readonly #maxPending: number;
44
+ readonly #concurrency: number;
45
+ readonly #onSubmitError?: (error: unknown) => void;
46
+ readonly #onDrop?: (event: DropEvent) => void;
47
+ readonly #queue: QueuedJob[] = [];
48
+ readonly #inFlight = new Set<Promise<void>>();
49
+
50
+ constructor(input: PublisherInput) {
51
+ this.#log = input.log;
52
+ const options = normalizeSubmitOptions(input.submit);
53
+ this.#mode = options.mode;
54
+ this.#maxPending = options.maxPending;
55
+ this.#concurrency = options.concurrency;
56
+ this.#onSubmitError = input.onSubmitError;
57
+ this.#onDrop = input.onDrop;
58
+ }
59
+
60
+ get mode(): SubmitMode {
61
+ return this.#mode;
62
+ }
63
+
64
+ async publish(job: PublishJob): Promise<TransparencyLogEntry | undefined> {
65
+ if (this.#mode === "await") {
66
+ return await this.#log.append(job.envelope, job.integratedTime);
67
+ }
68
+
69
+ if (this.#queue.length + this.#inFlight.size >= this.#maxPending) {
70
+ this.#onDrop?.({
71
+ envelope: new Uint8Array(job.envelope),
72
+ integratedTime: job.integratedTime,
73
+ reason: "queue_full",
74
+ });
75
+ return undefined;
76
+ }
77
+
78
+ const promise = new Promise<TransparencyLogEntry | undefined>((resolve, reject) => {
79
+ this.#queue.push({
80
+ envelope: new Uint8Array(job.envelope),
81
+ integratedTime: job.integratedTime,
82
+ resolve,
83
+ reject,
84
+ });
85
+ });
86
+ this.#drain();
87
+ return promise;
88
+ }
89
+
90
+ publishBackground(job: PublishJob): void {
91
+ void this.publish(job).catch((error) => {
92
+ this.#onSubmitError?.(error);
93
+ });
94
+ }
95
+
96
+ async flush(): Promise<void> {
97
+ while (this.#queue.length > 0 || this.#inFlight.size > 0) {
98
+ await Promise.allSettled([...this.#inFlight]);
99
+ }
100
+ }
101
+
102
+ #drain(): void {
103
+ while (this.#inFlight.size < this.#concurrency && this.#queue.length > 0) {
104
+ const job = this.#queue.shift() as QueuedJob;
105
+ const task = this.#submit(job).finally(() => {
106
+ this.#inFlight.delete(task);
107
+ this.#drain();
108
+ });
109
+ this.#inFlight.add(task);
110
+ }
111
+ }
112
+
113
+ async #submit(job: QueuedJob): Promise<void> {
114
+ try {
115
+ const entry = await this.#log.append(job.envelope, job.integratedTime);
116
+ job.resolve(entry);
117
+ } catch (error) {
118
+ this.#onSubmitError?.(error);
119
+ job.reject(error);
120
+ }
121
+ }
122
+ }
123
+
124
+ function normalizeSubmitOptions(
125
+ submit: SubmitMode | SelloSubmitOptions | undefined,
126
+ ): Required<SelloSubmitOptions> {
127
+ const options =
128
+ typeof submit === "string" || submit === undefined ? { mode: submit } : submit;
129
+ const mode = options.mode ?? "background";
130
+ if (mode !== "background" && mode !== "await") {
131
+ throw new TypeError("submit.mode must be background or await");
132
+ }
133
+
134
+ const maxPending = options.maxPending ?? 1000;
135
+ if (!Number.isSafeInteger(maxPending) || maxPending < 0) {
136
+ throw new TypeError("submit.maxPending must be a non-negative safe integer");
137
+ }
138
+
139
+ const concurrency = options.concurrency ?? 4;
140
+ if (!Number.isSafeInteger(concurrency) || concurrency < 1) {
141
+ throw new TypeError("submit.concurrency must be a positive safe integer");
142
+ }
143
+
144
+ return { mode, maxPending, concurrency };
145
+ }