did:web support, including our own internal kind

This commit is contained in:
lewis
2025-12-20 17:36:33 +02:00
parent adfde3bc1e
commit 3177dc42f5
13 changed files with 888 additions and 165 deletions
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, did FROM users WHERE handle = $1",
"query": "SELECT id, did, migrated_to_pds FROM users WHERE handle = $1",
"describe": {
"columns": [
{
@@ -12,6 +12,11 @@
"ordinal": 1,
"name": "did",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "migrated_to_pds",
"type_info": "Text"
}
],
"parameters": {
@@ -21,8 +26,9 @@
},
"nullable": [
false,
false
false,
true
]
},
"hash": "b2c53e6a278c4549c99a5b98cc7ca77fc1e9cd39a591c1d8ec1ca41adfffa3a6"
"hash": "9bd55935253b57b1b7e2d2bf69509e571af810234fa61368f58dd72e1d111cc5"
}
Generated
+10
View File
@@ -929,6 +929,15 @@ dependencies = [
"cfg_aliases",
]
[[package]]
name = "bs58"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
dependencies = [
"tinyvec",
]
[[package]]
name = "btree-range-map"
version = "0.7.2"
@@ -6175,6 +6184,7 @@ dependencies = [
"base32",
"base64 0.22.1",
"bcrypt",
"bs58",
"bytes",
"chrono",
"cid",
+1
View File
@@ -55,6 +55,7 @@ tower-http = { version = "0.6", features = ["fs", "cors"] }
hickory-resolver = { version = "0.24", features = ["tokio-runtime"] }
metrics = "0.24"
metrics-exporter-prometheus = { version = "0.16", default-features = false, features = ["http-listener"] }
bs58 = "0.5.1"
[features]
external-infra = []
[dev-dependencies]
+30 -14
View File
@@ -9,6 +9,21 @@ So like... make the thing unique, make it cool.
- [ ] Unique "brand" style both unauthed and authed
- [ ] Better documentation on how to sub out the entire frontend for whatever the users want
### Passkeys and 2FA
Modern passwordless authentication using WebAuthn/FIDO2, plus TOTP for defense in depth.
- [ ] passkeys table (id, did, credential_id, public_key, sign_count, created_at, last_used, friendly_name)
- [ ] user_totp table (did, secret_encrypted, verified, created_at, last_used)
- [ ] WebAuthn registration challenge generation and attestation verification
- [ ] TOTP secret generation with QR code setup flow
- [ ] Backup codes (hashed, one-time use) with recovery flow
- [ ] OAuth authorize flow: password -> 2FA (if enabled) -> passkey (as alternative)
- [ ] Passkey-only account creation (no password)
- [ ] Settings UI for managing passkeys, TOTP, backup codes
- [ ] Trusted devices option (remember this browser)
- [ ] Rate limit 2FA attempts
- [ ] Re-auth for sensitive actions (email change, adding new auth methods)
### Delegated accounts
Accounts controlled by other accounts rather than having their own password. When logging in as a delegated account, OAuth asks you to authenticate with a linked controller account. Uses OAuth scopes as the permission model.
@@ -26,20 +41,19 @@ Accounts controlled by other accounts rather than having their own password. Whe
- [ ] Log all actions with both actor DID and controller DID
- [ ] Audit log view for delegated account owners
### Passkeys and 2FA
Modern passwordless authentication using WebAuthn/FIDO2, plus TOTP for defense in depth.
### Migration tool
Seamless account migration built into the UI, inspired by pdsmoover. Users shouldn't need external tools or brain surgery on half-done account states.
- [ ] passkeys table (id, did, credential_id, public_key, sign_count, created_at, last_used, friendly_name)
- [ ] user_totp table (did, secret_encrypted, verified, created_at, last_used)
- [ ] WebAuthn registration challenge generation and attestation verification
- [ ] TOTP secret generation with QR code setup flow
- [ ] Backup codes (hashed, one-time use) with recovery flow
- [ ] OAuth authorize flow: password → 2FA (if enabled) → passkey (as alternative)
- [ ] Passkey-only account creation (no password)
- [ ] Settings UI for managing passkeys, TOTP, backup codes
- [ ] Trusted devices option (remember this browser)
- [ ] Rate limit 2FA attempts
- [ ] Re-auth for sensitive actions (email change, adding new auth methods)
- [ ] Add `migratingTo` parameter to `deactivateAccount` endpoint
- [ ] For self-hosted did:web users: set `migrated_to_pds`, update DID doc serviceEndpoint
- [ ] "Migrated" account state for self-hosted did:web: can authenticate but no repo operations
- [ ] Migrated did:web user UI: minimal dashboard with "update forwarding PDS" setting, or full migration wizard to handle PDS 2 -> PDS 3 moves automatically
- [ ] Outbound UI wizard: new PDS URL -> export repo -> guide account creation -> complete migration
- [ ] Inbound UI wizard: login to old PDS -> choose handle -> import -> PLC token flow
- [ ] Support `createAccount` with existing DID + service auth token
- [ ] Progress tracking with resume capability
- [ ] Scheduled automatic backups (CAR export)
- [ ] One-click restore from backup
### Plugin system
Extensible architecture allowing third-party plugins to add functionality, like minecraft mods or browser extensions.
@@ -74,7 +88,9 @@ Records that only authorized parties can see and decrypt. Requires key federatio
## Completed
Core ATProto: Health, describeServer, all session endpoints, full repo CRUD, applyWrites, blob upload, importRepo, firehose with cursor replay, CAR export, blob sync, crawler notifications, handle resolution, PLC operations, did:web, full admin API, moderation reports.
Core ATProto: Health, describeServer, all session endpoints, full repo CRUD, applyWrites, blob upload, importRepo, firehose with cursor replay, CAR export, blob sync, crawler notifications, handle resolution, PLC operations, full admin API, moderation reports.
did:web support: Self-hosted did:web (subdomain format `did:web:handle.pds.com`), external/BYOD did:web, DID document serving via `/.well-known/did.json`, migration tracking for did:web users who leave (serviceEndpoint redirect), clear registration warnings about did:web trade-offs vs did:plc.
OAuth 2.1: Authorization server metadata, JWKS, PAR, authorize endpoint with login UI, token endpoint (auth code + refresh), revocation, introspection, DPoP, PKCE S256, client metadata validation, private_key_jwt verification.
+6
View File
@@ -71,11 +71,15 @@ export interface InviteCode {
export type VerificationChannel = 'email' | 'discord' | 'telegram' | 'signal'
export type DidType = 'plc' | 'web' | 'web-external'
export interface CreateAccountParams {
handle: string
email: string
password: string
inviteCode?: string
didType?: DidType
did?: string
verificationChannel?: VerificationChannel
discordId?: string
telegramUsername?: string
@@ -109,6 +113,8 @@ export const api = {
email: params.email,
password: params.password,
inviteCode: params.inviteCode,
didType: params.didType,
did: params.did,
verificationChannel: params.verificationChannel,
discordId: params.discordId,
telegramUsername: params.telegramUsername,
+141 -1
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { register, getAuthState } from '../lib/auth.svelte'
import { navigate } from '../lib/router.svelte'
import { api, ApiError, type VerificationChannel } from '../lib/api'
import { api, ApiError, type VerificationChannel, type DidType } from '../lib/api'
const STORAGE_KEY = 'tranquil_pds_pending_verification'
@@ -14,6 +14,8 @@
let discordId = $state('')
let telegramUsername = $state('')
let signalNumber = $state('')
let didType = $state<DidType>('plc')
let externalDid = $state('')
let submitting = $state(false)
let error = $state<string | null>(null)
let serverInfo = $state<{
@@ -56,6 +58,10 @@
if (serverInfo?.inviteCodeRequired && !inviteCode.trim()) {
return 'Invite code is required'
}
if (didType === 'web-external') {
if (!externalDid.trim()) return 'External did:web is required'
if (!externalDid.trim().startsWith('did:web:')) return 'External DID must start with did:web:'
}
switch (verificationChannel) {
case 'email':
if (!email.trim()) return 'Email is required for email verification'
@@ -88,6 +94,8 @@
email: email.trim(),
password,
inviteCode: inviteCode.trim() || undefined,
didType,
did: didType === 'web-external' ? externalDid.trim() : undefined,
verificationChannel,
discordId: discordId.trim() || undefined,
telegramUsername: telegramUsername.trim() || undefined,
@@ -171,6 +179,76 @@
required
/>
</div>
<fieldset class="identity-section">
<legend>Identity Type</legend>
<p class="section-hint">Choose how your decentralized identity will be managed.</p>
<div class="radio-group">
<label class="radio-label">
<input
type="radio"
name="didType"
value="plc"
bind:group={didType}
disabled={submitting}
/>
<span class="radio-content">
<strong>did:plc</strong> (Recommended)
<span class="radio-hint">Portable identity managed by PLC Directory</span>
</span>
</label>
<label class="radio-label">
<input
type="radio"
name="didType"
value="web"
bind:group={didType}
disabled={submitting}
/>
<span class="radio-content">
<strong>did:web</strong>
<span class="radio-hint">Identity hosted on this PDS (read warning below)</span>
</span>
</label>
<label class="radio-label">
<input
type="radio"
name="didType"
value="web-external"
bind:group={didType}
disabled={submitting}
/>
<span class="radio-content">
<strong>did:web (BYOD)</strong>
<span class="radio-hint">Bring your own domain</span>
</span>
</label>
</div>
{#if didType === 'web'}
<div class="did-web-warning">
<strong>Important: Understand the trade-offs</strong>
<ul>
<li><strong>Permanent tie to this PDS:</strong> Your identity will be <code>did:web:yourhandle.{serverInfo?.availableUserDomains?.[0] || 'this-pds.com'}</code>. Even if you migrate to another PDS later, this server must continue hosting your DID document.</li>
<li><strong>No recovery mechanism:</strong> Unlike did:plc, did:web has no rotation keys. If this PDS goes offline permanently, your identity cannot be recovered.</li>
<li><strong>We commit to you:</strong> If you migrate away, we will continue serving a minimal DID document pointing to your new PDS. Your identity will remain functional.</li>
<li><strong>Recommendation:</strong> Choose did:plc unless you have a specific reason to prefer did:web.</li>
</ul>
</div>
{/if}
{#if didType === 'web-external'}
<div class="field">
<label for="external-did">Your did:web</label>
<input
id="external-did"
type="text"
bind:value={externalDid}
placeholder="did:web:yourdomain.com"
disabled={submitting}
required
/>
<p class="hint">Your domain must serve a valid DID document at /.well-known/did.json pointing to this PDS</p>
</div>
{/if}
</fieldset>
<fieldset class="verification-section">
<legend>Contact Method</legend>
<p class="section-hint">Choose how you'd like to verify your account and receive notifications. You only need one.</p>
@@ -323,11 +401,73 @@
padding: 0 0.5rem;
color: var(--text-primary);
}
.identity-section {
border: 1px solid var(--border-color-light);
border-radius: 6px;
padding: 1rem;
margin: 0.5rem 0;
}
.identity-section legend {
font-weight: 600;
padding: 0 0.5rem;
color: var(--text-primary);
}
.radio-group {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.radio-label {
display: flex;
align-items: flex-start;
gap: 0.5rem;
cursor: pointer;
}
.radio-label input[type="radio"] {
margin-top: 0.25rem;
}
.radio-content {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.radio-hint {
font-size: 0.75rem;
color: var(--text-secondary);
}
.section-hint {
font-size: 0.8rem;
color: var(--text-secondary);
margin: 0 0 1rem 0;
}
.did-web-warning {
margin-top: 1rem;
padding: 1rem;
background: var(--warning-bg, #fff3cd);
border: 1px solid var(--warning-border, #ffc107);
border-radius: 6px;
font-size: 0.875rem;
}
.did-web-warning strong {
color: var(--warning-text, #856404);
}
.did-web-warning ul {
margin: 0.75rem 0 0 0;
padding-left: 1.25rem;
}
.did-web-warning li {
margin-bottom: 0.5rem;
line-height: 1.4;
}
.did-web-warning li:last-child {
margin-bottom: 0;
}
.did-web-warning code {
background: rgba(0, 0, 0, 0.1);
padding: 0.125rem 0.25rem;
border-radius: 3px;
font-size: 0.8rem;
}
button {
padding: 0.75rem;
background: var(--accent);
@@ -0,0 +1,2 @@
ALTER TABLE users ADD COLUMN migrated_to_pds TEXT;
ALTER TABLE users ADD COLUMN migrated_at TIMESTAMPTZ;
+115 -71
View File
@@ -41,6 +41,7 @@ pub struct CreateAccountInput {
pub password: String,
pub invite_code: Option<String>,
pub did: Option<String>,
pub did_type: Option<String>,
pub signing_key: Option<String>,
pub verification_channel: Option<String>,
pub discord_id: Option<String>,
@@ -268,44 +269,35 @@ pub async fn create_account(
.into_response();
}
};
let did = if let Some(d) = &input.did {
if d.trim().is_empty() {
let rotation_key = std::env::var("PLC_ROTATION_KEY")
.unwrap_or_else(|_| signing_key_to_did_key(&signing_key));
let genesis_result = match create_genesis_operation(
&signing_key,
&rotation_key,
&full_handle,
&pds_endpoint,
) {
Ok(r) => r,
Err(e) => {
error!("Error creating PLC genesis operation: {:?}", e);
let did_type = input.did_type.as_deref().unwrap_or("plc");
let did = match did_type {
"web" => {
let subdomain_host = format!("{}.{}", input.handle, hostname);
let encoded_subdomain = subdomain_host.replace(':', "%3A");
let self_hosted_did = format!("did:web:{}", encoded_subdomain);
info!(did = %self_hosted_did, "Creating self-hosted did:web account (subdomain)");
self_hosted_did
}
"web-external" => {
let d = match &input.did {
Some(d) if !d.trim().is_empty() => d,
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to create PLC operation"})),
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidRequest", "message": "External did:web requires the 'did' field to be provided"})),
)
.into_response();
}
};
let plc_client = PlcClient::new(None);
if let Err(e) = plc_client
.send_operation(&genesis_result.did, &genesis_result.signed_operation)
.await
{
error!("Failed to submit PLC genesis operation: {:?}", e);
if !d.starts_with("did:web:") {
return (
StatusCode::BAD_GATEWAY,
Json(json!({
"error": "UpstreamError",
"message": format!("Failed to register DID with PLC directory: {}", e)
})),
StatusCode::BAD_REQUEST,
Json(
json!({"error": "InvalidDid", "message": "External DID must be a did:web"}),
),
)
.into_response();
}
info!(did = %genesis_result.did, "Successfully registered DID with PLC directory");
genesis_result.did
} else if d.starts_with("did:web:") {
if let Err(e) = verify_did_web(d, &hostname, &input.handle).await {
return (
StatusCode::BAD_REQUEST,
@@ -313,52 +305,104 @@ pub async fn create_account(
)
.into_response();
}
info!(did = %d, "Creating external did:web account");
d.clone()
} else if d.starts_with("did:plc:") && is_migration {
d.clone()
} else {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidDid", "message": "Only did:web DIDs can be provided; leave empty for did:plc. For migration with existing did:plc, provide service auth."})),
)
.into_response();
}
} else {
let rotation_key = std::env::var("PLC_ROTATION_KEY")
.unwrap_or_else(|_| signing_key_to_did_key(&signing_key));
let genesis_result = match create_genesis_operation(
&signing_key,
&rotation_key,
&full_handle,
&pds_endpoint,
) {
Ok(r) => r,
Err(e) => {
error!("Error creating PLC genesis operation: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to create PLC operation"})),
)
.into_response();
_ => {
if let Some(d) = &input.did {
if d.starts_with("did:plc:") && is_migration {
info!(did = %d, "Migration with existing did:plc");
d.clone()
} else if d.starts_with("did:web:") {
if let Err(e) = verify_did_web(d, &hostname, &input.handle).await {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidDid", "message": e})),
)
.into_response();
}
d.clone()
} else if !d.trim().is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(json!({"error": "InvalidDid", "message": "Only did:web DIDs can be provided; leave empty for did:plc. For migration with existing did:plc, provide service auth."})),
)
.into_response();
} else {
let rotation_key = std::env::var("PLC_ROTATION_KEY")
.unwrap_or_else(|_| signing_key_to_did_key(&signing_key));
let genesis_result = match create_genesis_operation(
&signing_key,
&rotation_key,
&full_handle,
&pds_endpoint,
) {
Ok(r) => r,
Err(e) => {
error!("Error creating PLC genesis operation: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to create PLC operation"})),
)
.into_response();
}
};
let plc_client = PlcClient::new(None);
if let Err(e) = plc_client
.send_operation(&genesis_result.did, &genesis_result.signed_operation)
.await
{
error!("Failed to submit PLC genesis operation: {:?}", e);
return (
StatusCode::BAD_GATEWAY,
Json(json!({
"error": "UpstreamError",
"message": format!("Failed to register DID with PLC directory: {}", e)
})),
)
.into_response();
}
info!(did = %genesis_result.did, "Successfully registered DID with PLC directory");
genesis_result.did
}
} else {
let rotation_key = std::env::var("PLC_ROTATION_KEY")
.unwrap_or_else(|_| signing_key_to_did_key(&signing_key));
let genesis_result = match create_genesis_operation(
&signing_key,
&rotation_key,
&full_handle,
&pds_endpoint,
) {
Ok(r) => r,
Err(e) => {
error!("Error creating PLC genesis operation: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError", "message": "Failed to create PLC operation"})),
)
.into_response();
}
};
let plc_client = PlcClient::new(None);
if let Err(e) = plc_client
.send_operation(&genesis_result.did, &genesis_result.signed_operation)
.await
{
error!("Failed to submit PLC genesis operation: {:?}", e);
return (
StatusCode::BAD_GATEWAY,
Json(json!({
"error": "UpstreamError",
"message": format!("Failed to register DID with PLC directory: {}", e)
})),
)
.into_response();
}
info!(did = %genesis_result.did, "Successfully registered DID with PLC directory");
genesis_result.did
}
};
let plc_client = PlcClient::new(None);
if let Err(e) = plc_client
.send_operation(&genesis_result.did, &genesis_result.signed_operation)
.await
{
error!("Failed to submit PLC genesis operation: {:?}", e);
return (
StatusCode::BAD_GATEWAY,
Json(json!({
"error": "UpstreamError",
"message": format!("Failed to register DID with PLC directory: {}", e)
})),
)
.into_response();
}
info!(did = %genesis_result.did, "Successfully registered DID with PLC directory");
genesis_result.did
};
let mut tx = match state.db.begin().await {
Ok(tx) => tx,
+233 -70
View File
@@ -95,9 +95,32 @@ pub fn get_jwk(key_bytes: &[u8]) -> Result<serde_json::Value, &'static str> {
}))
}
pub async fn well_known_did(State(_state): State<AppState>) -> impl IntoResponse {
pub fn get_public_key_multibase(key_bytes: &[u8]) -> Result<String, &'static str> {
let secret_key = SecretKey::from_slice(key_bytes).map_err(|_| "Invalid key length")?;
let public_key = secret_key.public_key();
let compressed = public_key.to_encoded_point(true);
let compressed_bytes = compressed.as_bytes();
let mut multicodec_key = vec![0xe7, 0x01];
multicodec_key.extend_from_slice(compressed_bytes);
Ok(format!("z{}", bs58::encode(&multicodec_key).into_string()))
}
pub async fn well_known_did(State(state): State<AppState>, headers: HeaderMap) -> Response {
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
// Kinda for local dev, encode hostname if it contains port
let host_header = headers
.get("host")
.and_then(|h| h.to_str().ok())
.unwrap_or(&hostname);
let host_without_port = host_header.split(':').next().unwrap_or(host_header);
let hostname_without_port = hostname.split(':').next().unwrap_or(&hostname);
if host_without_port != hostname_without_port
&& host_without_port.ends_with(&format!(".{}", hostname_without_port))
{
let handle = host_without_port
.strip_suffix(&format!(".{}", hostname_without_port))
.unwrap_or(host_without_port);
return serve_subdomain_did_doc(&state, handle, &hostname).await;
}
let did = if hostname.contains(':') {
format!("did:web:{}", hostname.replace(':', "%3A"))
} else {
@@ -112,15 +135,18 @@ pub async fn well_known_did(State(_state): State<AppState>) -> impl IntoResponse
"serviceEndpoint": format!("https://{}", hostname)
}]
}))
.into_response()
}
pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<String>) -> Response {
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let user = sqlx::query!("SELECT id, did FROM users WHERE handle = $1", handle)
.fetch_optional(&state.db)
.await;
let (user_id, did) = match user {
Ok(Some(row)) => (row.id, row.did),
async fn serve_subdomain_did_doc(state: &AppState, handle: &str, hostname: &str) -> Response {
let user = sqlx::query!(
"SELECT id, did, migrated_to_pds FROM users WHERE handle = $1",
handle
)
.fetch_optional(&state.db)
.await;
let (user_id, did, migrated_to_pds) = match user {
Ok(Some(row)) => (row.id, row.did, row.migrated_to_pds),
Ok(None) => {
return (StatusCode::NOT_FOUND, Json(json!({"error": "NotFound"}))).into_response();
}
@@ -140,6 +166,16 @@ pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<Stri
)
.into_response();
}
let subdomain_host = format!("{}.{}", handle, hostname);
let encoded_subdomain = subdomain_host.replace(':', "%3A");
let expected_self_hosted = format!("did:web:{}", encoded_subdomain);
if did != expected_self_hosted {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "NotFound", "message": "External did:web - DID document hosted by user"})),
)
.into_response();
}
let key_row = sqlx::query!(
"SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1",
user_id
@@ -165,10 +201,10 @@ pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<Stri
.into_response();
}
};
let jwk = match get_jwk(&key_bytes) {
Ok(j) => j,
let public_key_multibase = match get_public_key_multibase(&key_bytes) {
Ok(pk) => pk,
Err(e) => {
tracing::error!("Failed to generate JWK: {}", e);
tracing::error!("Failed to generate public key multibase: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
@@ -176,25 +212,148 @@ pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<Stri
.into_response();
}
};
let full_handle = if handle.contains('.') {
handle.to_string()
} else {
format!("{}.{}", handle, hostname)
};
let service_endpoint = migrated_to_pds.unwrap_or_else(|| format!("https://{}", hostname));
Json(json!({
"@context": ["https://www.w3.org/ns/did/v1", "https://w3id.org/security/suites/jws-2020/v1"],
"@context": [
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/multikey/v1",
"https://w3id.org/security/suites/secp256k1-2019/v1"
],
"id": did,
"alsoKnownAs": [format!("at://{}", handle)],
"alsoKnownAs": [format!("at://{}", full_handle)],
"verificationMethod": [{
"id": format!("{}#atproto", did),
"type": "JsonWebKey2020",
"type": "Multikey",
"controller": did,
"publicKeyJwk": jwk
"publicKeyMultibase": public_key_multibase
}],
"service": [{
"id": "#atproto_pds",
"type": "AtprotoPersonalDataServer",
"serviceEndpoint": format!("https://{}", hostname)
"serviceEndpoint": service_endpoint
}]
})).into_response()
}))
.into_response()
}
pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<String>) -> Response {
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let user = sqlx::query!(
"SELECT id, did, migrated_to_pds FROM users WHERE handle = $1",
handle
)
.fetch_optional(&state.db)
.await;
let (user_id, did, migrated_to_pds) = match user {
Ok(Some(row)) => (row.id, row.did, row.migrated_to_pds),
Ok(None) => {
return (StatusCode::NOT_FOUND, Json(json!({"error": "NotFound"}))).into_response();
}
Err(e) => {
error!("DB Error: {:?}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
if !did.starts_with("did:web:") {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "NotFound", "message": "User is not did:web"})),
)
.into_response();
}
let encoded_hostname = hostname.replace(':', "%3A");
let old_path_format = format!("did:web:{}:u:{}", encoded_hostname, handle);
let subdomain_host = format!("{}.{}", handle, hostname);
let encoded_subdomain = subdomain_host.replace(':', "%3A");
let new_subdomain_format = format!("did:web:{}", encoded_subdomain);
if did != old_path_format && did != new_subdomain_format {
return (
StatusCode::NOT_FOUND,
Json(json!({"error": "NotFound", "message": "External did:web - DID document hosted by user"})),
)
.into_response();
}
let key_row = sqlx::query!(
"SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1",
user_id
)
.fetch_optional(&state.db)
.await;
let key_bytes: Vec<u8> = match key_row {
Ok(Some(row)) => match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) {
Ok(k) => k,
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
},
_ => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
let public_key_multibase = match get_public_key_multibase(&key_bytes) {
Ok(pk) => pk,
Err(e) => {
tracing::error!("Failed to generate public key multibase: {}", e);
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": "InternalError"})),
)
.into_response();
}
};
let full_handle = if handle.contains('.') {
handle.clone()
} else {
format!("{}.{}", handle, hostname)
};
let service_endpoint = migrated_to_pds.unwrap_or_else(|| format!("https://{}", hostname));
Json(json!({
"@context": [
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/multikey/v1",
"https://w3id.org/security/suites/secp256k1-2019/v1"
],
"id": did,
"alsoKnownAs": [format!("at://{}", full_handle)],
"verificationMethod": [{
"id": format!("{}#atproto", did),
"type": "Multikey",
"controller": did,
"publicKeyMultibase": public_key_multibase
}],
"service": [{
"id": "#atproto_pds",
"type": "AtprotoPersonalDataServer",
"serviceEndpoint": service_endpoint
}]
}))
.into_response()
}
pub async fn verify_did_web(did: &str, hostname: &str, handle: &str) -> Result<(), String> {
let subdomain_host = format!("{}.{}", handle, hostname);
let encoded_subdomain = subdomain_host.replace(':', "%3A");
let expected_subdomain_did = format!("did:web:{}", encoded_subdomain);
if did == expected_subdomain_did {
return Ok(());
}
let expected_prefix = if hostname.contains(':') {
format!("did:web:{}", hostname.replace(':', "%3A"))
} else {
@@ -204,62 +363,61 @@ pub async fn verify_did_web(did: &str, hostname: &str, handle: &str) -> Result<(
let suffix = &did[expected_prefix.len()..];
let expected_suffix = format!(":u:{}", handle);
if suffix == expected_suffix {
Ok(())
return Ok(());
} else {
Err(format!(
return Err(format!(
"Invalid DID path for this PDS. Expected {}",
expected_suffix
))
));
}
}
let parts: Vec<&str> = did.split(':').collect();
if parts.len() < 3 || parts[0] != "did" || parts[1] != "web" {
return Err("Invalid did:web format".into());
}
let domain_segment = parts[2];
let domain = domain_segment.replace("%3A", ":");
let scheme = if domain.starts_with("localhost") || domain.starts_with("127.0.0.1") {
"http"
} else {
let parts: Vec<&str> = did.split(':').collect();
if parts.len() < 3 || parts[0] != "did" || parts[1] != "web" {
return Err("Invalid did:web format".into());
}
let domain_segment = parts[2];
let domain = domain_segment.replace("%3A", ":");
let scheme = if domain.starts_with("localhost") || domain.starts_with("127.0.0.1") {
"http"
} else {
"https"
};
let url = if parts.len() == 3 {
format!("{}://{}/.well-known/did.json", scheme, domain)
} else {
let path = parts[3..].join("/");
format!("{}://{}/{}/did.json", scheme, domain, path)
};
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.map_err(|e| format!("Failed to create client: {}", e))?;
let resp = client
.get(&url)
.send()
.await
.map_err(|e| format!("Failed to fetch DID doc: {}", e))?;
if !resp.status().is_success() {
return Err(format!("Failed to fetch DID doc: HTTP {}", resp.status()));
}
let doc: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Failed to parse DID doc: {}", e))?;
let services = doc["service"]
.as_array()
.ok_or("No services found in DID doc")?;
let pds_endpoint = format!("https://{}", hostname);
let has_valid_service = services.iter().any(|s| {
s["type"] == "AtprotoPersonalDataServer" && s["serviceEndpoint"] == pds_endpoint
});
if has_valid_service {
Ok(())
} else {
Err(format!(
"DID document does not list this PDS ({}) as AtprotoPersonalDataServer",
pds_endpoint
))
}
"https"
};
let url = if parts.len() == 3 {
format!("{}://{}/.well-known/did.json", scheme, domain)
} else {
let path = parts[3..].join("/");
format!("{}://{}/{}/did.json", scheme, domain, path)
};
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.map_err(|e| format!("Failed to create client: {}", e))?;
let resp = client
.get(&url)
.send()
.await
.map_err(|e| format!("Failed to fetch DID doc: {}", e))?;
if !resp.status().is_success() {
return Err(format!("Failed to fetch DID doc: HTTP {}", resp.status()));
}
let doc: serde_json::Value = resp
.json()
.await
.map_err(|e| format!("Failed to parse DID doc: {}", e))?;
let services = doc["service"]
.as_array()
.ok_or("No services found in DID doc")?;
let pds_endpoint = format!("https://{}", hostname);
let has_valid_service = services
.iter()
.any(|s| s["type"] == "AtprotoPersonalDataServer" && s["serviceEndpoint"] == pds_endpoint);
if has_valid_service {
Ok(())
} else {
Err(format!(
"DID document does not list this PDS ({}) as AtprotoPersonalDataServer",
pds_endpoint
))
}
}
@@ -344,10 +502,15 @@ pub async fn get_recommended_did_credentials(
Err(_) => return ApiError::InternalError.into_response(),
};
let did_key = signing_key_to_did_key(&signing_key);
let rotation_keys = if auth_user.did.starts_with("did:web:") {
vec![]
} else {
vec![did_key.clone()]
};
(
StatusCode::OK,
Json(GetRecommendedDidCredentialsOutput {
rotation_keys: vec![did_key.clone()],
rotation_keys,
also_known_as: vec![format!("at://{}", full_handle)],
verification_methods: VerificationMethods { atproto: did_key },
services: Services {
+6
View File
@@ -63,6 +63,12 @@ pub async fn sign_plc_operation(
return e;
}
let did = &auth_user.did;
if did.starts_with("did:web:") {
return ApiError::InvalidRequest(
"PLC operations are only valid for did:plc identities".into(),
)
.into_response();
}
let token = match &input.token {
Some(t) => t,
None => {
+6
View File
@@ -42,6 +42,12 @@ pub async fn submit_plc_operation(
return e;
}
let did = &auth_user.did;
if did.starts_with("did:web:") {
return ApiError::InvalidRequest(
"PLC operations are only valid for did:plc identities".into(),
)
.into_response();
}
if let Err(e) = validate_plc_operation(&input.operation) {
return ApiError::InvalidRequest(format!("Invalid operation: {}", e)).into_response();
}
+324
View File
@@ -0,0 +1,324 @@
mod common;
use common::*;
use reqwest::StatusCode;
use serde_json::{Value, json};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test]
async fn test_create_self_hosted_did_web() {
let client = client();
let handle = format!("selfweb_{}", uuid::Uuid::new_v4());
let payload = json!({
"handle": handle,
"email": format!("{}@example.com", handle),
"password": "password",
"didType": "web"
});
let res = client
.post(format!(
"{}/xrpc/com.atproto.server.createAccount",
base_url().await
))
.json(&payload)
.send()
.await
.expect("Failed to send request");
if res.status() != StatusCode::OK {
let body: Value = res.json().await.unwrap_or(json!({"error": "parse failed"}));
panic!("createAccount failed: {:?}", body);
}
let body: Value = res.json().await.expect("Response was not JSON");
let did = body["did"].as_str().expect("No DID in response");
assert!(
did.starts_with("did:web:"),
"DID should start with did:web:, got: {}",
did
);
assert!(
did.contains(&handle),
"DID should contain handle {}, got: {}",
handle,
did
);
assert!(
!did.contains(":u:"),
"Self-hosted did:web should use subdomain format (no :u:), got: {}",
did
);
let jwt = verify_new_account(&client, did).await;
let res = client
.get(format!("{}/u/{}/did.json", base_url().await, handle))
.send()
.await
.expect("Failed to fetch DID doc via path");
assert_eq!(
res.status(),
StatusCode::OK,
"Self-hosted did:web should have DID doc served by PDS (via path for backwards compat)"
);
let doc: Value = res.json().await.expect("DID doc was not JSON");
assert_eq!(doc["id"], did);
assert!(
doc["verificationMethod"][0]["publicKeyMultibase"].is_string(),
"DID doc should have publicKeyMultibase"
);
let res = client
.post(format!(
"{}/xrpc/com.atproto.repo.createRecord",
base_url().await
))
.bearer_auth(&jwt)
.json(&json!({
"repo": did,
"collection": "app.bsky.feed.post",
"record": {
"$type": "app.bsky.feed.post",
"text": "Hello from did:web!",
"createdAt": chrono::Utc::now().to_rfc3339()
}
}))
.send()
.await
.expect("Failed to create post");
assert_eq!(
res.status(),
StatusCode::OK,
"Self-hosted did:web account should be able to create records"
);
}
#[tokio::test]
async fn test_external_did_web_no_local_doc() {
let client = client();
let mock_server = MockServer::start().await;
let mock_uri = mock_server.uri();
let mock_addr = mock_uri.trim_start_matches("http://");
let did = format!("did:web:{}", mock_addr.replace(":", "%3A"));
let handle = format!("extweb_{}", uuid::Uuid::new_v4());
let pds_endpoint = base_url().await.replace("http://", "https://");
let did_doc = json!({
"@context": ["https://www.w3.org/ns/did/v1"],
"id": did,
"service": [{
"id": "#atproto_pds",
"type": "AtprotoPersonalDataServer",
"serviceEndpoint": pds_endpoint
}]
});
Mock::given(method("GET"))
.and(path("/.well-known/did.json"))
.respond_with(ResponseTemplate::new(200).set_body_json(did_doc))
.mount(&mock_server)
.await;
let payload = json!({
"handle": handle,
"email": format!("{}@example.com", handle),
"password": "password",
"didType": "web-external",
"did": did
});
let res = client
.post(format!(
"{}/xrpc/com.atproto.server.createAccount",
base_url().await
))
.json(&payload)
.send()
.await
.expect("Failed to send request");
if res.status() != StatusCode::OK {
let body: Value = res.json().await.unwrap_or(json!({"error": "parse failed"}));
panic!("createAccount failed: {:?}", body);
}
let res = client
.get(format!("{}/u/{}/did.json", base_url().await, handle))
.send()
.await
.expect("Failed to fetch DID doc");
assert_eq!(
res.status(),
StatusCode::NOT_FOUND,
"External did:web should NOT have DID doc served by PDS"
);
let body: Value = res.json().await.expect("Response was not JSON");
assert!(
body["message"].as_str().unwrap_or("").contains("External"),
"Error message should indicate external did:web"
);
}
#[tokio::test]
async fn test_plc_operations_blocked_for_did_web() {
let client = client();
let handle = format!("plcblock_{}", uuid::Uuid::new_v4());
let payload = json!({
"handle": handle,
"email": format!("{}@example.com", handle),
"password": "password",
"didType": "web"
});
let res = client
.post(format!(
"{}/xrpc/com.atproto.server.createAccount",
base_url().await
))
.json(&payload)
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.expect("Response was not JSON");
let did = body["did"].as_str().expect("No DID").to_string();
let jwt = verify_new_account(&client, &did).await;
let res = client
.post(format!(
"{}/xrpc/com.atproto.identity.signPlcOperation",
base_url().await
))
.bearer_auth(&jwt)
.json(&json!({
"token": "fake-token"
}))
.send()
.await
.expect("Failed to send request");
assert_eq!(
res.status(),
StatusCode::BAD_REQUEST,
"signPlcOperation should be blocked for did:web users"
);
let body: Value = res.json().await.expect("Response was not JSON");
assert!(
body["message"].as_str().unwrap_or("").contains("did:plc"),
"Error should mention did:plc: {:?}",
body
);
let res = client
.post(format!(
"{}/xrpc/com.atproto.identity.submitPlcOperation",
base_url().await
))
.bearer_auth(&jwt)
.json(&json!({
"operation": {}
}))
.send()
.await
.expect("Failed to send request");
assert_eq!(
res.status(),
StatusCode::BAD_REQUEST,
"submitPlcOperation should be blocked for did:web users"
);
}
#[tokio::test]
async fn test_get_recommended_did_credentials_no_rotation_keys_for_did_web() {
let client = client();
let handle = format!("creds_{}", uuid::Uuid::new_v4());
let payload = json!({
"handle": handle,
"email": format!("{}@example.com", handle),
"password": "password",
"didType": "web"
});
let res = client
.post(format!(
"{}/xrpc/com.atproto.server.createAccount",
base_url().await
))
.json(&payload)
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.expect("Response was not JSON");
let did = body["did"].as_str().expect("No DID").to_string();
let jwt = verify_new_account(&client, &did).await;
let res = client
.get(format!(
"{}/xrpc/com.atproto.identity.getRecommendedDidCredentials",
base_url().await
))
.bearer_auth(&jwt)
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.expect("Response was not JSON");
let rotation_keys = body["rotationKeys"]
.as_array()
.expect("rotationKeys should be an array");
assert!(
rotation_keys.is_empty(),
"did:web should have no rotation keys, got: {:?}",
rotation_keys
);
assert!(
body["verificationMethods"].is_object(),
"verificationMethods should be present"
);
assert!(body["services"].is_object(), "services should be present");
}
#[tokio::test]
async fn test_did_plc_still_works_with_did_type_param() {
let client = client();
let handle = format!("plctype_{}", uuid::Uuid::new_v4());
let payload = json!({
"handle": handle,
"email": format!("{}@example.com", handle),
"password": "password",
"didType": "plc"
});
let res = client
.post(format!(
"{}/xrpc/com.atproto.server.createAccount",
base_url().await
))
.json(&payload)
.send()
.await
.expect("Failed to send request");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.expect("Response was not JSON");
let did = body["did"].as_str().expect("No DID").to_string();
assert!(
did.starts_with("did:plc:"),
"DID with didType=plc should be did:plc:, got: {}",
did
);
}
#[tokio::test]
async fn test_external_did_web_requires_did_field() {
let client = client();
let handle = format!("nodid_{}", uuid::Uuid::new_v4());
let payload = json!({
"handle": handle,
"email": format!("{}@example.com", handle),
"password": "password",
"didType": "web-external"
});
let res = client
.post(format!(
"{}/xrpc/com.atproto.server.createAccount",
base_url().await
))
.json(&payload)
.send()
.await
.expect("Failed to send request");
assert_eq!(
res.status(),
StatusCode::BAD_REQUEST,
"web-external without did should fail"
);
let body: Value = res.json().await.expect("Response was not JSON");
assert!(
body["message"].as_str().unwrap_or("").contains("did"),
"Error should mention did field is required: {:?}",
body
);
}
+5 -6
View File
@@ -143,12 +143,11 @@ async fn test_create_did_web_account_and_resolve() {
.send()
.await
.expect("Failed to fetch DID doc");
assert_eq!(res.status(), StatusCode::OK);
let doc: Value = res.json().await.expect("DID doc was not JSON");
assert_eq!(doc["id"], did);
assert_eq!(doc["alsoKnownAs"][0], format!("at://{}", handle));
assert_eq!(doc["verificationMethod"][0]["controller"], did);
assert!(doc["verificationMethod"][0]["publicKeyJwk"].is_object());
assert_eq!(
res.status(),
StatusCode::NOT_FOUND,
"External did:web should not have DID doc served by PDS (user hosts their own)"
);
}
#[tokio::test]