diff --git a/knot2/crates/knot-atproto/src/lib.rs b/knot2/crates/knot-atproto/src/lib.rs index 06cb5f892..d5e6e5b40 100644 --- a/knot2/crates/knot-atproto/src/lib.rs +++ b/knot2/crates/knot-atproto/src/lib.rs @@ -820,7 +820,7 @@ fn list_records_url( ) -> Result { let method: Nsid = Nsid::new_static("com.atproto.repo.listRecords").expect("Literal NSID parses"); - let collection: Nsid = Nsid::new_static("sh.tangled.publicKey").expect("Literal NSID parses"); + let collection: Nsid = Nsid::new_static("org.tangled.key.sshKey").expect("Literal NSID parses"); let mut url = xrpc_url(pds, &method)?; url.query_pairs_mut() .append_pair("repo", did.as_str()) @@ -1416,7 +1416,7 @@ mod tests { .url .query() .unwrap() - .contains("sh.tangled.publicKey") + .contains("org.tangled.key.sshKey") ); counter.fetch_add(1, Ordering::SeqCst); Ok(ok(body.clone())) @@ -1670,7 +1670,7 @@ mod tests { assert_eq!(url.path(), "/xrpc/com.atproto.repo.listRecords"); let query = url.query().unwrap(); assert!(query.contains("repo=did%3Aplc%3Asquid")); - assert!(query.contains("collection=sh.tangled.publicKey")); + assert!(query.contains("collection=org.tangled.key.sshKey")); } #[test] diff --git a/knot2/crates/knot-atproto/src/pubkeys.rs b/knot2/crates/knot-atproto/src/pubkeys.rs index d39134e34..d791d1f9e 100644 --- a/knot2/crates/knot-atproto/src/pubkeys.rs +++ b/knot2/crates/knot-atproto/src/pubkeys.rs @@ -71,7 +71,17 @@ struct Envelope { #[derive(Deserialize)] struct KeyRecord { - key: String, + payload: String, + #[serde(default)] + usages: Option>, +} + +impl KeyRecord { + fn authenticates(&self) -> bool { + self.usages + .as_ref() + .is_none_or(|usages| usages.iter().any(|usage| usage == "auth")) + } } pub(crate) struct PubkeyPage { @@ -85,7 +95,8 @@ pub(crate) fn offered_page(body: &[u8], max_keys: usize) -> Result) -> Bytes { .enumerate() .map(|(index, line)| { serde_json::json!({ - "uri": format!("at://{SQUID}/sh.tangled.publicKey/{index}"), - "value": { "$type": "sh.tangled.publicKey", "key": line, "name": "k", "createdAt": "2026-06-08T00:00:00Z" } + "uri": format!("at://{SQUID}/org.tangled.key.sshKey/{index}"), + "value": { "$type": "org.tangled.key.sshKey", "payload": line, "name": "k", "createdAt": "2026-06-08T00:00:00Z" } }) }) .collect(); diff --git a/knot2/crates/knot-sim/tests/lfs_roundtrip.rs b/knot2/crates/knot-sim/tests/lfs_roundtrip.rs index 698278c05..2bb8f7f2d 100644 --- a/knot2/crates/knot-sim/tests/lfs_roundtrip.rs +++ b/knot2/crates/knot-sim/tests/lfs_roundtrip.rs @@ -159,10 +159,10 @@ fn did_document(did: &str, pds: &str) -> Vec { fn list_records_body(public_line: &str) -> Vec { serde_json::to_vec(&serde_json::json!({ "records": [{ - "uri": format!("at://{OWNER_DID}/sh.tangled.publicKey/1"), + "uri": format!("at://{OWNER_DID}/org.tangled.key.sshKey/1"), "value": { - "$type": "sh.tangled.publicKey", - "key": public_line, + "$type": "org.tangled.key.sshKey", + "payload": public_line, "name": "laptop", "createdAt": "2026-07-01T00:00:00Z" } diff --git a/knot2/crates/knot-sim/tests/ssh.rs b/knot2/crates/knot-sim/tests/ssh.rs index 71fef223f..36fa53543 100644 --- a/knot2/crates/knot-sim/tests/ssh.rs +++ b/knot2/crates/knot-sim/tests/ssh.rs @@ -84,10 +84,10 @@ fn did_document(signer: &K256Signer, did: &str) -> Vec { fn list_records_body(public_line: &str) -> Vec { serde_json::to_vec(&serde_json::json!({ "records": [{ - "uri": format!("at://{OWNER_DID}/sh.tangled.publicKey/1"), + "uri": format!("at://{OWNER_DID}/org.tangled.key.sshKey/1"), "value": { - "$type": "sh.tangled.publicKey", - "key": public_line, + "$type": "org.tangled.key.sshKey", + "payload": public_line, "name": "laptop", "createdAt": "2026-06-08T00:00:00Z" } diff --git a/knot2/crates/knot-ssh/examples/ephemeral_knot.rs b/knot2/crates/knot-ssh/examples/ephemeral_knot.rs index 78212683a..753208abf 100644 --- a/knot2/crates/knot-ssh/examples/ephemeral_knot.rs +++ b/knot2/crates/knot-ssh/examples/ephemeral_knot.rs @@ -88,8 +88,8 @@ fn list_records_body(published_line: &str) -> Vec { serde_json::to_vec(&serde_json::json!({ "records": [{ "value": { - "$type": "sh.tangled.publicKey", - "key": published_line, + "$type": "org.tangled.key.sshKey", + "payload": published_line, "name": "laptop", "createdAt": "2026-06-08T00:00:00Z" } diff --git a/knot2/crates/knot-ssh/tests/ssh_push.rs b/knot2/crates/knot-ssh/tests/ssh_push.rs index 1b4e753c6..8ab10501a 100644 --- a/knot2/crates/knot-ssh/tests/ssh_push.rs +++ b/knot2/crates/knot-ssh/tests/ssh_push.rs @@ -98,8 +98,8 @@ fn list_records_body(lines: &[&str]) -> Vec { .map(|line| { serde_json::json!({ "value": { - "$type": "sh.tangled.publicKey", - "key": line, + "$type": "org.tangled.key.sshKey", + "payload": line, "name": "laptop", "createdAt": "2026-06-08T00:00:00Z" } diff --git a/web/lex.config.ts b/web/lex.config.ts index 0a46230c3..9c27e31c0 100644 --- a/web/lex.config.ts +++ b/web/lex.config.ts @@ -30,6 +30,7 @@ export default defineLexiconConfig({ "../lexicons/org/tangled/feed/*.json", "../lexicons/org/tangled/git/*.json", "../lexicons/org/tangled/graph/*.json", + "../lexicons/org/tangled/key/*.json", "../lexicons/org/tangled/knot/*.json", "../lexicons/org/tangled/label/*.json", "../lexicons/org/tangled/markup/*.json", diff --git a/web/src/lib/api/githubImport.test.ts b/web/src/lib/api/githubImport.test.ts index 3e4d9fcd2..bfd61fe04 100644 --- a/web/src/lib/api/githubImport.test.ts +++ b/web/src/lib/api/githubImport.test.ts @@ -386,7 +386,7 @@ describe("importGitHubProfile", () => { mockReadRecord.mockResolvedValue(null); mockCreatePubKey.mockReset(); mockCreatePubKey.mockImplementation(async (agent, name) => ({ - uri: `at://${agent.sub}/sh.tangled.publicKey/${name}`, + uri: `at://${agent.sub}/org.tangled.key.sshKey/${name}`, cid: "bafycreated", rkey: `rkey-${name}` })); @@ -539,7 +539,7 @@ describe("importGitHubProfile", () => { records: [ { value: { - key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIG123 old-comment" + payload: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIG123 old-comment" } } ], @@ -551,7 +551,7 @@ describe("importGitHubProfile", () => { }; mockRpcCall.mockImplementation(async (_schema, options) => { - if (options?.params?.collection === "sh.tangled.publicKey") { + if (options?.params?.collection === "org.tangled.key.sshKey") { if (options?.params?.cursor === "cursor-2") { return { ok: true, data: pdsPage2 }; } diff --git a/web/src/lib/api/githubImport.ts b/web/src/lib/api/githubImport.ts index f401a9d83..b2c2058db 100644 --- a/web/src/lib/api/githubImport.ts +++ b/web/src/lib/api/githubImport.ts @@ -37,7 +37,7 @@ export const invalidateGitHubAccount = (fetcher: typeof fetch = fetch) => accountCache.delete(fetcher); const PROFILE_COLLECTION = nsidOf("org.tangled.actor.profile"); -const PUBLIC_KEY_COLLECTION = "sh.tangled.publicKey" as Nsid; +const PUBLIC_KEY_COLLECTION = "org.tangled.key.sshKey" as Nsid; const SELF_RKEY = recordKeyOf("self"); const GITHUB_REPO_GRAMMAR = /^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/; @@ -365,9 +365,9 @@ export const importGitHubProfile = async ( }) ); for (const rec of page.records) { - const val = rec.value as { key?: unknown }; - if (typeof val?.key === "string") { - const token = normalizeSshKey(val.key); + const val = rec.value as { payload?: unknown }; + if (typeof val?.payload === "string") { + const token = normalizeSshKey(val.payload); if (token) existingTokens.add(token); } } diff --git a/web/src/lib/api/lexicons/index.ts b/web/src/lib/api/lexicons/index.ts index baaac9601..066ee610d 100644 --- a/web/src/lib/api/lexicons/index.ts +++ b/web/src/lib/api/lexicons/index.ts @@ -25,6 +25,7 @@ export * as OrgTangledGraphAttestation from "./types/org/tangled/graph/attestati export * as OrgTangledGraphCollaborator from "./types/org/tangled/graph/collaborator.js"; export * as OrgTangledGraphFollow from "./types/org/tangled/graph/follow.js"; export * as OrgTangledGraphVouch from "./types/org/tangled/graph/vouch.js"; +export * as OrgTangledKeySshKey from "./types/org/tangled/key/sshKey.js"; export * as OrgTangledKnotDescribeKnot from "./types/org/tangled/knot/describeKnot.js"; export * as OrgTangledLabelDefinition from "./types/org/tangled/label/definition.js"; export * as OrgTangledLabelDefs from "./types/org/tangled/label/defs.js"; diff --git a/web/src/lib/api/lexicons/types/org/tangled/key/sshKey.ts b/web/src/lib/api/lexicons/types/org/tangled/key/sshKey.ts new file mode 100644 index 000000000..6d70eb63c --- /dev/null +++ b/web/src/lib/api/lexicons/types/org/tangled/key/sshKey.ts @@ -0,0 +1,50 @@ +import type {} from "@atcute/lexicons"; +import * as v from "@atcute/lexicons/validations"; +import type {} from "@atcute/lexicons/ambient"; + +const _mainSchema = /*#__PURE__*/ v.record( + /*#__PURE__*/ v.tidString(), + /*#__PURE__*/ v.object({ + $type: /*#__PURE__*/ v.literal("org.tangled.key.sshKey"), + createdAt: /*#__PURE__*/ v.datetimeString(), + /** + * human-readable name for this key + * @maxLength 255 + */ + name: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(0, 255), + ]), + /** + * @maxLength 4096 + */ + payload: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ + /*#__PURE__*/ v.stringLength(0, 4096), + ]), + /** + * default to `["auth", "sign"]` when omitted. + * @minLength 1 + */ + usages: /*#__PURE__*/ v.optional( + /*#__PURE__*/ v.constrain( + /*#__PURE__*/ v.array( + /*#__PURE__*/ v.string<"auth" | "sign" | (string & {})>(), + ), + [/*#__PURE__*/ v.arrayLength(1)], + ), + ), + }), +); + +type main$schematype = typeof _mainSchema; + +export interface mainSchema extends main$schematype {} + +export const mainSchema = _mainSchema as mainSchema; + +export interface Main extends v.InferInput {} + +declare module "@atcute/lexicons/ambient" { + interface Records { + "org.tangled.key.sshKey": mainSchema; + } +} diff --git a/web/src/lib/api/settings.ts b/web/src/lib/api/settings.ts index fa7589a8f..a51096c35 100644 --- a/web/src/lib/api/settings.ts +++ b/web/src/lib/api/settings.ts @@ -6,11 +6,11 @@ import type { BobbinContext, XrpcRequestInit } from "$lib/api/client"; import { nsidOf, recordKeyOf } from "$lib/api/syntax"; import { rkeyFromUri } from "$lib/api/uri"; import { createRecord, deleteRecord } from "$lib/api/write"; -import type * as ShTangledPublicKey from "$lib/api/lexicons/types/sh/tangled/publicKey"; +import type * as OrgTangledKeySshKey from "$lib/api/lexicons/types/org/tangled/key/sshKey"; -export type PublicKeyRecord = ShTangledPublicKey.Main; +export type PublicKeyRecord = OrgTangledKeySshKey.Main; -const PUBLIC_KEY_COLLECTION = nsidOf("sh.tangled.publicKey"); +const PUBLIC_KEY_COLLECTION = nsidOf("org.tangled.key.sshKey"); // ssh SHA256 fingerprint of the base64 key blob, matching `ssh-keygen -lf`. export const sshFingerprint = async (pubkey: string): Promise => { const parts = pubkey.trim().split(/\s+/); @@ -70,9 +70,9 @@ export const createPubKey = async ( agent, PUBLIC_KEY_COLLECTION, { - $type: "sh.tangled.publicKey", + $type: "org.tangled.key.sshKey", name, - key, + payload: key, createdAt: new Date().toISOString() }, rkey diff --git a/web/src/lib/components/settings/tabs/KeysTab.svelte b/web/src/lib/components/settings/tabs/KeysTab.svelte index e72ea305e..5c96df3c0 100644 --- a/web/src/lib/components/settings/tabs/KeysTab.svelte +++ b/web/src/lib/components/settings/tabs/KeysTab.svelte @@ -40,7 +40,7 @@ const ctx = createBobbinClient({ serviceUrl }); const keys = await listPubKeys(ctx, did); const entries = await Promise.all( - keys.map(async (k) => [k.rkey, await sshFingerprint(k.value.key)] as const) + keys.map(async (k) => [k.rkey, await sshFingerprint(k.value.payload)] as const) ); const fingerprints = Object.fromEntries( entries.filter(([, fp]) => fp !== null) as [string, string][] diff --git a/web/src/lib/oauth-client-metadata.ts b/web/src/lib/oauth-client-metadata.ts index 08b70a3e9..f0fa8d0f9 100644 --- a/web/src/lib/oauth-client-metadata.ts +++ b/web/src/lib/oauth-client-metadata.ts @@ -13,6 +13,7 @@ const scopes = [ "repo:org.tangled.graph.vouch", "repo:org.tangled.graph.attestation", "repo:org.tangled.repo.declaration", + "repo:org.tangled.key.sshKey", "repo:sh.tangled.publicKey", "repo:sh.tangled.knot", "repo:sh.tangled.knot.member", diff --git a/web/src/routes/welcome/+page.ts b/web/src/routes/welcome/+page.ts index bb8b4b23b..4316723d9 100644 --- a/web/src/routes/welcome/+page.ts +++ b/web/src/routes/welcome/+page.ts @@ -33,7 +33,7 @@ export const load: PageLoad = async (event) => { keys.map(async ({ rkey, value }) => ({ rkey, name: value.name, - fingerprint: (await sshFingerprint(value.key)) ?? undefined, + fingerprint: (await sshFingerprint(value.payload)) ?? undefined, createdAt: value.createdAt })) );