a collection of lightweight TypeScript packages for AT Protocol, the protocol powering Bluesky
atproto bluesky typescript npm
101
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(did-plc): new additions

Mary b01eb657 fc1e9b01

+340 -9
+5
.changeset/bright-keys-sign.md
··· 1 + --- 2 + '@atcute/did-plc': minor 3 + --- 4 + 5 + add `signOperation` and `signTombstone` functions for signing unsigned PLC operations
+7
.changeset/hungry-plc-client.md
··· 1 + --- 2 + '@atcute/did-plc': minor 3 + --- 4 + 5 + add `PlcClient` for interacting with plc.directory API 6 + 7 + includes new types `PlcState` and `SequencedEntry` for API responses
+5
.changeset/smooth-dids-derive.md
··· 1 + --- 2 + '@atcute/did-plc': minor 3 + --- 4 + 5 + add `deriveDidFromGenesisOp` to derive did:plc identifier from genesis operation
+41 -2
packages/identity/did-plc/README.md
··· 1 1 # @atcute/did-plc 2 2 3 - validate and process did:plc operation logs. 3 + validate, sign, and interact with did:plc operations. 4 4 5 5 ```sh 6 6 npm install @atcute/did-plc 7 7 ``` 8 8 9 9 did:plc is a self-certifying DID method where the audit log serves as the source of truth. this 10 - package validates that operations are properly signed and chained. 10 + package validates that operations are properly signed and chained, and provides utilities for 11 + creating and submitting new operations. 11 12 12 13 ## usage 14 + 15 + ### using the client 16 + 17 + ```ts 18 + import { PlcClient } from '@atcute/did-plc'; 19 + 20 + const client = new PlcClient(); 21 + 22 + // fetch DID document 23 + const doc = await client.getDocument('did:plc:ragtjsm2j2vknwkz3zp4oxrd'); 24 + 25 + // fetch current identity state 26 + const state = await client.getState('did:plc:ragtjsm2j2vknwkz3zp4oxrd'); 27 + 28 + // fetch operation log 29 + const log = await client.getOperationLog('did:plc:ragtjsm2j2vknwkz3zp4oxrd'); 30 + 31 + // fetch last operation 32 + const lastOp = await client.getLastOperation('did:plc:ragtjsm2j2vknwkz3zp4oxrd'); 33 + 34 + // submit a signed operation 35 + await client.submitOperation('did:plc:ragtjsm2j2vknwkz3zp4oxrd', signedOperation); 36 + ``` 37 + 38 + ### signing operations 39 + 40 + ```ts 41 + import { signOperation, signTombstone, deriveDidFromGenesisOp } from '@atcute/did-plc'; 42 + 43 + // sign an unsigned operation 44 + const signedOp = await signOperation(unsignedOp, rotationKey); 45 + 46 + // sign a tombstone 47 + const signedTombstone = await signTombstone(unsignedTombstone, rotationKey); 48 + 49 + // derive did:plc from genesis operation 50 + const did = await deriveDidFromGenesisOp(signedGenesisOp); 51 + ``` 13 52 14 53 ### validating audit logs 15 54
+195
packages/identity/did-plc/lib/client.ts
··· 1 + import type { DidDocument } from '@atcute/identity'; 2 + import { defs as identityDefs } from '@atcute/identity'; 3 + import { FailedResponseError, isResponseOk, parseResponseAsJson, pipe, validateJsonWith } from '@atcute/util-fetch'; 4 + 5 + import * as defs from './typedefs.js'; 6 + import * as t from './types.js'; 7 + 8 + const MAX_RESPONSE_SIZE = 64 * 1024; 9 + 10 + const handleDocument = pipe( 11 + isResponseOk, 12 + parseResponseAsJson(/^application\/(did\+ld\+)?json$/, MAX_RESPONSE_SIZE), 13 + validateJsonWith(identityDefs.didDocument, { mode: 'passthrough' }), 14 + ); 15 + 16 + const handlePlcState = pipe( 17 + isResponseOk, 18 + parseResponseAsJson(/^application\/json$/, MAX_RESPONSE_SIZE), 19 + validateJsonWith(defs.plcState, { mode: 'passthrough' }), 20 + ); 21 + 22 + const handleOperationLog = pipe( 23 + isResponseOk, 24 + parseResponseAsJson(/^application\/json$/, MAX_RESPONSE_SIZE), 25 + validateJsonWith(defs.operationLog, { mode: 'passthrough' }), 26 + ); 27 + 28 + const handleIndexedEntryLog = pipe( 29 + isResponseOk, 30 + parseResponseAsJson(/^application\/json$/, MAX_RESPONSE_SIZE), 31 + validateJsonWith(defs.indexedEntryLog, { mode: 'passthrough' }), 32 + ); 33 + 34 + const handleLastOperation = pipe( 35 + isResponseOk, 36 + parseResponseAsJson(/^application\/json$/, MAX_RESPONSE_SIZE), 37 + validateJsonWith(defs.compatibleOperationOrTombstone, { mode: 'passthrough' }), 38 + ); 39 + 40 + export interface PlcClientOptions { 41 + /** plc directory URL, defaults to https://plc.directory */ 42 + serviceUrl?: string; 43 + /** custom fetch function */ 44 + fetch?: typeof globalThis.fetch; 45 + } 46 + 47 + export interface PlcRequestOptions { 48 + signal?: AbortSignal; 49 + } 50 + 51 + /** 52 + * client for interacting with plc.directory 53 + */ 54 + export class PlcClient { 55 + readonly serviceUrl: string; 56 + #fetch: typeof globalThis.fetch; 57 + 58 + constructor({ serviceUrl = 'https://plc.directory', fetch: fetchFn = fetch }: PlcClientOptions = {}) { 59 + this.serviceUrl = serviceUrl; 60 + this.#fetch = fetchFn; 61 + } 62 + 63 + /** 64 + * fetches the DID document for a did:plc 65 + * @param did the did:plc identifier 66 + * @param options request options 67 + * @returns the DID document 68 + */ 69 + async getDocument(did: t.DidPlcString, options?: PlcRequestOptions): Promise<DidDocument> { 70 + const url = new URL(`/${encodeURIComponent(did)}`, this.serviceUrl); 71 + 72 + const response = await this.#fetch(url, { 73 + signal: options?.signal, 74 + headers: { accept: 'application/did+ld+json,application/json' }, 75 + }); 76 + 77 + const { json } = await handleDocument(response); 78 + return json; 79 + } 80 + 81 + /** 82 + * fetches the current identity state for a did:plc 83 + * @param did the did:plc identifier 84 + * @param options request options 85 + * @returns the current plc state 86 + */ 87 + async getState(did: t.DidPlcString, options?: PlcRequestOptions): Promise<t.PlcState> { 88 + const url = new URL(`/${encodeURIComponent(did)}/data`, this.serviceUrl); 89 + 90 + const response = await this.#fetch(url, { 91 + signal: options?.signal, 92 + headers: { accept: 'application/json' }, 93 + }); 94 + 95 + const { json } = await handlePlcState(response); 96 + return json; 97 + } 98 + 99 + /** 100 + * fetches the operation log for a did:plc 101 + * @param did the did:plc identifier 102 + * @param options request options 103 + * @returns the operation log 104 + */ 105 + async getOperationLog(did: t.DidPlcString, options?: PlcRequestOptions): Promise<t.OperationLog> { 106 + const url = new URL(`/${encodeURIComponent(did)}/log`, this.serviceUrl); 107 + 108 + const response = await this.#fetch(url, { 109 + signal: options?.signal, 110 + headers: { accept: 'application/json' }, 111 + }); 112 + 113 + const { json } = await handleOperationLog(response); 114 + return json; 115 + } 116 + 117 + /** 118 + * fetches the auditable log for a did:plc (includes CIDs and timestamps) 119 + * @param did the did:plc identifier 120 + * @param options request options 121 + * @returns the indexed entry log 122 + */ 123 + async getAuditLog(did: t.DidPlcString, options?: PlcRequestOptions): Promise<t.IndexedEntryLog> { 124 + const url = new URL(`/${encodeURIComponent(did)}/log/audit`, this.serviceUrl); 125 + 126 + const response = await this.#fetch(url, { 127 + signal: options?.signal, 128 + headers: { accept: 'application/json' }, 129 + }); 130 + 131 + const { json } = await handleIndexedEntryLog(response); 132 + return json; 133 + } 134 + 135 + /** 136 + * fetches the last operation for a did:plc 137 + * @param did the did:plc identifier 138 + * @param options request options 139 + * @returns the last operation or tombstone 140 + */ 141 + async getLastOperation( 142 + did: t.DidPlcString, 143 + options?: PlcRequestOptions, 144 + ): Promise<t.CompatibleOperationOrTombstone> { 145 + const url = new URL(`/${encodeURIComponent(did)}/log/last`, this.serviceUrl); 146 + 147 + const response = await this.#fetch(url, { 148 + signal: options?.signal, 149 + headers: { accept: 'application/json' }, 150 + }); 151 + 152 + const { json } = await handleLastOperation(response); 153 + return json; 154 + } 155 + 156 + /** 157 + * submits a signed operation to plc.directory 158 + * @param did the did:plc identifier 159 + * @param operation the signed operation to submit 160 + * @param options request options 161 + */ 162 + async submitOperation( 163 + did: t.DidPlcString, 164 + operation: t.OperationOrTombstone, 165 + options?: PlcRequestOptions, 166 + ): Promise<void> { 167 + const url = new URL(`/${encodeURIComponent(did)}`, this.serviceUrl); 168 + 169 + const response = await this.#fetch(url, { 170 + method: 'POST', 171 + signal: options?.signal, 172 + headers: { 'content-type': 'application/json' }, 173 + body: JSON.stringify(operation), 174 + }); 175 + 176 + if (!response.ok) { 177 + throw new FailedResponseError(response); 178 + } 179 + } 180 + 181 + /** 182 + * checks if the plc directory is reachable 183 + * @param options request options 184 + * @returns true if reachable 185 + */ 186 + async ping(options?: PlcRequestOptions): Promise<boolean> { 187 + const url = new URL('/_health', this.serviceUrl); 188 + 189 + const response = await this.#fetch(url, { 190 + signal: options?.signal, 191 + }); 192 + 193 + return response.ok; 194 + } 195 + }
+3 -6
packages/identity/did-plc/lib/data.ts
··· 1 1 import * as CBOR from '@atcute/cbor'; 2 2 import * as CID from '@atcute/cid'; 3 3 import { isKeyDid } from '@atcute/identity'; 4 - import { toBase32 } from '@atcute/multibase'; 5 - import { toSha256 } from '@atcute/uint8array'; 6 4 7 5 import { DISPUTE_WINDOW } from './constants.js'; 8 6 import * as err from './errors.js'; 9 7 import * as t from './types.js'; 10 - import { isSignedOperationValid, normalizeOp } from './utils.js'; 8 + import { deriveDidFromGenesisOp, isSignedOperationValid, normalizeOp } from './utils.js'; 11 9 12 10 // soft constraint limits for incoming operations 13 11 const MAX_OP_BYTES = 4000; ··· 148 146 149 147 // Check if CID and DID matches 150 148 { 151 - const opBytes = CBOR.encode(proposed.operation); 152 - 153 - const expectedDid = `did:plc:${toBase32(await toSha256(opBytes)).slice(0, 24)}`; 149 + const expectedDid = await deriveDidFromGenesisOp(proposed.operation); 154 150 if (expectedDid !== did) { 155 151 throw new err.GenesisHashError(proposed, did); 156 152 } 157 153 154 + const opBytes = CBOR.encode(proposed.operation); 158 155 const expectedCid = CID.toString(await CID.create(CID.CODEC_DCBOR, opBytes)); 159 156 if (expectedCid !== proposed.cid) { 160 157 throw new err.InvalidHashError(proposed, expectedCid);
+1
packages/identity/did-plc/lib/index.ts
··· 1 1 export * as defs from './typedefs.js'; 2 2 export * from './types.js'; 3 3 4 + export * from './client.js'; 4 5 export * from './constants.js'; 5 6 export * from './data.js'; 6 7 export * from './errors.js';
+15
packages/identity/did-plc/lib/typedefs.ts
··· 120 120 .tuple([_indexedEntry.extend({ operation: compatibleOperation })]) 121 121 .concat(v.array(_indexedEntry.extend({ operation: operationOrTombstone }))); 122 122 // #endregion 123 + 124 + // #region Client response schemas 125 + export const plcState: v.Type<t.PlcState> = v.object({ 126 + did: didPlcString, 127 + rotationKeys: v.array(didKeyString), 128 + verificationMethods: v.record(permissiveDidKeyString), 129 + alsoKnownAs: v.array(v.string()), 130 + services: v.record(service), 131 + }); 132 + 133 + export const sequencedEntry: v.Type<t.SequencedEntry> = _indexedEntry.extend({ 134 + type: v.literal('sequenced_op'), 135 + seq: v.number(), 136 + }); 137 + // #endregion
+23
packages/identity/did-plc/lib/types.ts
··· 71 71 genesis: IndexedEntry<CompatibleOperation>, 72 72 ...IndexedEntry<OperationOrTombstone>[], 73 73 ]; 74 + 75 + // #region client response types 76 + 77 + /** 78 + * current identity state derived from the did:plc operation log 79 + */ 80 + export interface PlcState { 81 + did: DidPlcString; 82 + rotationKeys: DidKeyString[]; 83 + verificationMethods: Record<string, DidKeyString>; 84 + alsoKnownAs: string[]; 85 + services: Record<string, Service>; 86 + } 87 + 88 + /** 89 + * operation entry with sequence number from /export endpoint 90 + */ 91 + export interface SequencedEntry extends IndexedEntry { 92 + type: 'sequenced_op'; 93 + seq: number; 94 + } 95 + 96 + // #endregion
+44 -1
packages/identity/did-plc/lib/utils.ts
··· 1 1 import * as CBOR from '@atcute/cbor'; 2 + import type { PrivateKey } from '@atcute/crypto'; 2 3 import { verifySigWithDidKey } from '@atcute/crypto'; 3 - import { fromBase64Url } from '@atcute/multibase'; 4 + import { fromBase64Url, toBase32, toBase64Url } from '@atcute/multibase'; 5 + import { toSha256 } from '@atcute/uint8array'; 4 6 5 7 import * as t from './types.js'; 6 8 ··· 20 22 const stripped = str.replace('http://', '').replace('https://', ''); 21 23 22 24 return `at://${stripped}`; 25 + }; 26 + 27 + /** 28 + * derives the did:plc identifier from a genesis operation 29 + * @param op signed genesis operation 30 + * @returns the did:plc string 31 + */ 32 + export const deriveDidFromGenesisOp = async (op: t.CompatibleOperation): Promise<t.DidPlcString> => { 33 + const opBytes = CBOR.encode(op); 34 + const hash = await toSha256(opBytes); 35 + return `did:plc:${toBase32(hash).slice(0, 24)}`; 23 36 }; 24 37 25 38 export const normalizeOp = (op: t.CompatibleOperation): t.Operation => { ··· 64 77 65 78 return null; 66 79 }; 80 + 81 + // #region signing operations 82 + 83 + /** 84 + * signs an unsigned plc operation 85 + * @param op unsigned operation to sign 86 + * @param key private key to sign with (must be one of the rotation keys) 87 + * @returns signed operation 88 + */ 89 + export const signOperation = async (op: t.UnsignedOperation, key: PrivateKey): Promise<t.Operation> => { 90 + const data = CBOR.encode(op); 91 + const sig = await key.sign(data); 92 + 93 + return { ...op, sig: toBase64Url(sig) }; 94 + }; 95 + 96 + /** 97 + * signs an unsigned plc tombstone 98 + * @param op unsigned tombstone to sign 99 + * @param key private key to sign with (must be one of the rotation keys) 100 + * @returns signed tombstone 101 + */ 102 + export const signTombstone = async (op: t.UnsignedTombstone, key: PrivateKey): Promise<t.Tombstone> => { 103 + const data = CBOR.encode(op); 104 + const sig = await key.sign(data); 105 + 106 + return { ...op, sig: toBase64Url(sig) }; 107 + }; 108 + 109 + // #endregion
+1
packages/identity/did-plc/package.json
··· 43 43 "@atcute/lexicons": "workspace:^", 44 44 "@atcute/multibase": "workspace:^", 45 45 "@atcute/uint8array": "workspace:^", 46 + "@atcute/util-fetch": "workspace:^", 46 47 "@badrap/valita": "^0.4.6" 47 48 } 48 49 }