cat / goq
clone

turns a linux box into a private steam streaming appliance: plug in, tap your key, play from another computer

main
goq / crates / sigil-protocol / src / media_auth.rs
media_auth.rs 24.2K
1use base64::Engine as _;2use base64::engine::general_purpose::URL_SAFE_NO_PAD;3use ed25519_dalek::{Signature, Signer as _, SigningKey, VerifyingKey};4use sha2::{Digest as _, Sha256};56use crate::{MAX_MEDIA_PAYLOAD_LEN, MEDIA_HEADER_LEN, ProtocolError, Result};78const CERTIFICATE_MAGIC: [u8; 4] = *b"SGMC";9const CERTIFICATE_VERSION: u16 = 1;10const CERTIFICATE_CLAIMS_LEN: usize = 96;11const SIGNATURE_LEN: usize = 64;12const CERTIFICATE_LEN: usize = CERTIFICATE_CLAIMS_LEN + SIGNATURE_LEN;13const CERTIFICATE_DOMAIN: &[u8] = b"goq.sh/sigil-media-certificate/v1\0";14const OBJECT_MAGIC: [u8; 4] = *b"SGMA";15const OBJECT_VERSION: u16 = 1;16const OBJECT_UNSIGNED_HEADER_LEN: usize = 70;17const OBJECT_DOMAIN: &[u8] = b"goq.sh/sigil-media-object/v1\0";18const MAX_GENERATION_CERTIFICATE_TTL_SECS: u64 = 24 * 60 * 60;1920pub const MEDIA_GENERATION_CERTIFICATE_TOKEN_PREFIX: &str = "goq-media-cert-v1.";21pub const MAX_MEDIA_GENERATION_CERTIFICATE_TOKEN_LEN: usize = 256;22pub const AUTHENTICATED_MEDIA_OBJECT_HEADER_LEN: usize = OBJECT_UNSIGNED_HEADER_LEN + SIGNATURE_LEN;23const MAX_AUTHENTICATED_PAYLOAD_LEN: usize = MEDIA_HEADER_LEN + MAX_MEDIA_PAYLOAD_LEN;2425#[derive(Clone, Copy, Debug, PartialEq, Eq)]26#[repr(u8)]27pub enum MediaAuthMode {28 Ed25519 = 1,29}3031impl MediaAuthMode {32 pub const fn label(self) -> &'static str {33 match self {34 Self::Ed25519 => "ed25519-v1",35 }36 }37}3839impl TryFrom<u8> for MediaAuthMode {40 type Error = ProtocolError;4142 fn try_from(value: u8) -> Result<Self> {43 match value {44 1 => Ok(Self::Ed25519),45 _ => invalid(46 "media authentication mode",47 "unsupported authentication mode",48 ),49 }50 }51}5253#[derive(Clone, Copy, Debug, PartialEq, Eq)]54#[repr(u8)]55pub enum MediaTrack {56 VideoH264 = 1,57 AudioOpus = 2,58}5960impl MediaTrack {61 pub const fn name(self) -> &'static str {62 match self {63 Self::VideoH264 => crate::MOQ_VIDEO_H264_TRACK,64 Self::AudioOpus => "audio/opus",65 }66 }67}6869impl TryFrom<u8> for MediaTrack {70 type Error = ProtocolError;7172 fn try_from(value: u8) -> Result<Self> {73 match value {74 1 => Ok(Self::VideoH264),75 2 => Ok(Self::AudioOpus),76 _ => invalid("authenticated media object", "unsupported media track"),77 }78 }79}8081#[derive(Clone, Debug, PartialEq, Eq)]82pub struct MediaGenerationCertificateClaims {83 pub host_node_id: [u8; 32],84 pub generation_id: u64,85 pub generation_signing_key: [u8; 32],86 pub issued_at_unix: u64,87 pub expires_at_unix: u64,88 pub authentication_mode: MediaAuthMode,89}9091impl MediaGenerationCertificateClaims {92 pub fn new(93 host_node_id: [u8; 32],94 generation_id: u64,95 generation_signing_key: [u8; 32],96 issued_at_unix: u64,97 expires_at_unix: u64,98 ) -> Result<Self> {99 let claims = Self {100 host_node_id,101 generation_id,102 generation_signing_key,103 issued_at_unix,104 expires_at_unix,105 authentication_mode: MediaAuthMode::Ed25519,106 };107 claims.validate()?;108 Ok(claims)109 }110111 pub fn validate(&self) -> Result<()> {112 if self.authentication_mode != MediaAuthMode::Ed25519 {113 return invalid(114 "media generation certificate",115 "unsupported authentication mode",116 );117 }118 if self.generation_id == 0 {119 return invalid(120 "media generation certificate",121 "generation id must be non-zero",122 );123 }124 if self.issued_at_unix >= self.expires_at_unix125 || self.expires_at_unix - self.issued_at_unix > MAX_GENERATION_CERTIFICATE_TTL_SECS126 {127 return invalid(128 "media generation certificate",129 "certificate lifetime must be within 1..=86400 seconds",130 );131 }132 VerifyingKey::from_bytes(&self.host_node_id).map_err(|_| {133 ProtocolError::InvalidMessage {134 message_type: "media generation certificate",135 reason: "host node id is not an Ed25519 public key",136 }137 })?;138 VerifyingKey::from_bytes(&self.generation_signing_key).map_err(|_| {139 ProtocolError::InvalidMessage {140 message_type: "media generation certificate",141 reason: "generation key is not an Ed25519 public key",142 }143 })?;144 Ok(())145 }146147 fn encode(&self) -> [u8; CERTIFICATE_CLAIMS_LEN] {148 let mut bytes = [0_u8; CERTIFICATE_CLAIMS_LEN];149 bytes[0..4].copy_from_slice(&CERTIFICATE_MAGIC);150 bytes[4..6].copy_from_slice(&CERTIFICATE_VERSION.to_be_bytes());151 bytes[6] = self.authentication_mode as u8;152 bytes[8..40].copy_from_slice(&self.host_node_id);153 bytes[40..48].copy_from_slice(&self.generation_id.to_be_bytes());154 bytes[48..80].copy_from_slice(&self.generation_signing_key);155 bytes[80..88].copy_from_slice(&self.issued_at_unix.to_be_bytes());156 bytes[88..96].copy_from_slice(&self.expires_at_unix.to_be_bytes());157 bytes158 }159160 fn signing_message(&self) -> Vec<u8> {161 let mut message = Vec::with_capacity(CERTIFICATE_DOMAIN.len() + CERTIFICATE_CLAIMS_LEN);162 message.extend_from_slice(CERTIFICATE_DOMAIN);163 message.extend_from_slice(&self.encode());164 message165 }166}167168#[derive(Clone, Debug, PartialEq, Eq)]169pub struct SignedMediaGenerationCertificate {170 pub claims: MediaGenerationCertificateClaims,171 signature: [u8; SIGNATURE_LEN],172}173174impl SignedMediaGenerationCertificate {175 pub fn issue(claims: MediaGenerationCertificateClaims, host_secret: &[u8; 32]) -> Result<Self> {176 claims.validate()?;177 let signing_key = SigningKey::from_bytes(host_secret);178 if signing_key.verifying_key().to_bytes() != claims.host_node_id {179 return invalid(180 "media generation certificate",181 "host signing key does not match the certificate host",182 );183 }184 let signature = signing_key.sign(&claims.signing_message()).to_bytes();185 Ok(Self { claims, signature })186 }187188 pub fn verify(&self) -> Result<()> {189 self.claims.validate()?;190 let key = VerifyingKey::from_bytes(&self.claims.host_node_id).map_err(|_| {191 ProtocolError::InvalidMessage {192 message_type: "media generation certificate",193 reason: "invalid host verification key",194 }195 })?;196 key.verify_strict(197 &self.claims.signing_message(),198 &Signature::from_bytes(&self.signature),199 )200 .map_err(|_| ProtocolError::InvalidMessage {201 message_type: "media generation certificate",202 reason: "host signature verification failed",203 })204 }205206 pub fn verify_binding(207 &self,208 expected_host: [u8; 32],209 expected_generation: u64,210 now_unix: u64,211 ) -> Result<()> {212 self.verify()?;213 if self.claims.host_node_id != expected_host {214 return invalid(215 "media generation certificate",216 "certificate is bound to another host",217 );218 }219 if self.claims.generation_id != expected_generation {220 return invalid(221 "media generation certificate",222 "certificate is bound to another generation",223 );224 }225 if now_unix < self.claims.issued_at_unix || now_unix > self.claims.expires_at_unix {226 return invalid(227 "media generation certificate",228 "certificate is not currently valid",229 );230 }231 Ok(())232 }233234 pub fn encode(&self) -> String {235 let mut bytes = Vec::with_capacity(CERTIFICATE_LEN);236 bytes.extend_from_slice(&self.claims.encode());237 bytes.extend_from_slice(&self.signature);238 format!(239 "{MEDIA_GENERATION_CERTIFICATE_TOKEN_PREFIX}{}",240 URL_SAFE_NO_PAD.encode(bytes)241 )242 }243244 pub fn decode(token: &str) -> Result<Self> {245 if token.len() > MAX_MEDIA_GENERATION_CERTIFICATE_TOKEN_LEN246 || token.contains('=')247 || token.bytes().any(|byte| byte.is_ascii_whitespace())248 {249 return invalid(250 "media generation certificate",251 "certificate token is oversized or non-canonical",252 );253 }254 let encoded = token255 .strip_prefix(MEDIA_GENERATION_CERTIFICATE_TOKEN_PREFIX)256 .ok_or(ProtocolError::InvalidMessage {257 message_type: "media generation certificate",258 reason: "certificate token prefix is invalid",259 })?;260 let bytes = URL_SAFE_NO_PAD261 .decode(encoded)262 .map_err(|_| ProtocolError::InvalidMessage {263 message_type: "media generation certificate",264 reason: "certificate token is not canonical base64url",265 })?;266 if bytes.len() != CERTIFICATE_LEN {267 return Err(ProtocolError::InvalidMessageLength {268 actual: bytes.len(),269 maximum: CERTIFICATE_LEN,270 });271 }272 if bytes[0..4] != CERTIFICATE_MAGIC {273 return invalid(274 "media generation certificate",275 "certificate magic is invalid",276 );277 }278 let version = u16::from_be_bytes(bytes[4..6].try_into().expect("fixed certificate"));279 if version != CERTIFICATE_VERSION {280 return Err(ProtocolError::UnsupportedVersion {281 expected: CERTIFICATE_VERSION,282 actual: version,283 });284 }285 if bytes[7] != 0 {286 return invalid(287 "media generation certificate",288 "reserved fields must be zero",289 );290 }291 let claims = MediaGenerationCertificateClaims {292 authentication_mode: MediaAuthMode::try_from(bytes[6])?,293 host_node_id: bytes[8..40].try_into().expect("fixed certificate"),294 generation_id: u64::from_be_bytes(bytes[40..48].try_into().expect("fixed certificate")),295 generation_signing_key: bytes[48..80].try_into().expect("fixed certificate"),296 issued_at_unix: u64::from_be_bytes(297 bytes[80..88].try_into().expect("fixed certificate"),298 ),299 expires_at_unix: u64::from_be_bytes(300 bytes[88..96].try_into().expect("fixed certificate"),301 ),302 };303 let certificate = Self {304 claims,305 signature: bytes[CERTIFICATE_CLAIMS_LEN..]306 .try_into()307 .expect("fixed certificate signature"),308 };309 certificate.verify()?;310 if certificate.encode() != token {311 return invalid(312 "media generation certificate",313 "certificate encoding is not canonical",314 );315 }316 Ok(certificate)317 }318}319320pub struct MediaGenerationSigningKey(SigningKey);321322impl Clone for MediaGenerationSigningKey {323 fn clone(&self) -> Self {324 Self(self.0.clone())325 }326}327328impl std::fmt::Debug for MediaGenerationSigningKey {329 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {330 formatter.write_str("MediaGenerationSigningKey([REDACTED])")331 }332}333334impl MediaGenerationSigningKey {335 pub fn from_bytes(secret: &[u8; 32]) -> Self {336 Self(SigningKey::from_bytes(secret))337 }338339 pub fn public_key(&self) -> [u8; 32] {340 self.0.verifying_key().to_bytes()341 }342343 pub fn certify(344 &self,345 host_node_id: [u8; 32],346 host_secret: &[u8; 32],347 generation_id: u64,348 issued_at_unix: u64,349 expires_at_unix: u64,350 ) -> Result<SignedMediaGenerationCertificate> {351 SignedMediaGenerationCertificate::issue(352 MediaGenerationCertificateClaims::new(353 host_node_id,354 generation_id,355 self.public_key(),356 issued_at_unix,357 expires_at_unix,358 )?,359 host_secret,360 )361 }362363 pub fn authenticate(364 &self,365 coordinates: MediaObjectCoordinates,366 payload: &[u8],367 ) -> Result<AuthenticatedMediaObject> {368 coordinates.validate(payload.len())?;369 let digest: [u8; 32] = Sha256::digest(payload).into();370 let unsigned = encode_unsigned_object_header(coordinates, payload.len(), digest)?;371 let mut message = Vec::with_capacity(OBJECT_DOMAIN.len() + unsigned.len());372 message.extend_from_slice(OBJECT_DOMAIN);373 message.extend_from_slice(&unsigned);374 let signature = self.0.sign(&message).to_bytes();375 let mut bytes = Vec::with_capacity(AUTHENTICATED_MEDIA_OBJECT_HEADER_LEN + payload.len());376 bytes.extend_from_slice(&unsigned);377 bytes.extend_from_slice(&signature);378 bytes.extend_from_slice(payload);379 Ok(AuthenticatedMediaObject { bytes })380 }381}382383#[derive(Clone, Copy, Debug, PartialEq, Eq)]384pub struct MediaObjectCoordinates {385 pub generation_id: u64,386 pub track: MediaTrack,387 pub group_id: u64,388 pub object_id: u32,389 pub flags: u16,390}391392impl MediaObjectCoordinates {393 fn validate(&self, payload_len: usize) -> Result<()> {394 if self.generation_id == 0 {395 return invalid(396 "authenticated media object",397 "generation id must be non-zero",398 );399 }400 if self.flags & !0x0007 != 0 {401 return invalid("authenticated media object", "unknown media flags are set");402 }403 if payload_len == 0 || payload_len > MAX_AUTHENTICATED_PAYLOAD_LEN {404 return Err(ProtocolError::InvalidMediaPayloadLength {405 actual: payload_len,406 maximum: MAX_AUTHENTICATED_PAYLOAD_LEN,407 });408 }409 u32::try_from(payload_len).map_err(|_| ProtocolError::InvalidMediaPayloadLength {410 actual: payload_len,411 maximum: MAX_AUTHENTICATED_PAYLOAD_LEN,412 })?;413 Ok(())414 }415}416417#[derive(Clone, Debug, PartialEq, Eq)]418pub struct AuthenticatedMediaObject {419 bytes: Vec<u8>,420}421422impl AuthenticatedMediaObject {423 pub fn as_bytes(&self) -> &[u8] {424 &self.bytes425 }426427 pub fn into_bytes(self) -> Vec<u8> {428 self.bytes429 }430431 pub fn coordinates(object: &[u8]) -> Result<MediaObjectCoordinates> {432 if object.len() < AUTHENTICATED_MEDIA_OBJECT_HEADER_LEN {433 return invalid(434 "authenticated media object",435 "object ended before its authentication header",436 );437 }438 let unsigned = &object[..OBJECT_UNSIGNED_HEADER_LEN];439 if unsigned[0..4] != OBJECT_MAGIC {440 return invalid(441 "authenticated media object",442 "authentication magic is invalid",443 );444 }445 let version = u16::from_be_bytes(unsigned[4..6].try_into().expect("fixed auth header"));446 if version != OBJECT_VERSION {447 return Err(ProtocolError::UnsupportedVersion {448 expected: OBJECT_VERSION,449 actual: version,450 });451 }452 let header_len = u16::from_be_bytes(unsigned[6..8].try_into().expect("fixed auth header"));453 if usize::from(header_len) != AUTHENTICATED_MEDIA_OBJECT_HEADER_LEN {454 return invalid(455 "authenticated media object",456 "authentication header length is invalid",457 );458 }459 if MediaAuthMode::try_from(unsigned[8])? != MediaAuthMode::Ed25519460 || unsigned[12..14].iter().any(|byte| *byte != 0)461 {462 return invalid(463 "authenticated media object",464 "authentication mode or reserved fields are invalid",465 );466 }467 let coordinates = MediaObjectCoordinates {468 generation_id: u64::from_be_bytes(469 unsigned[14..22].try_into().expect("fixed auth header"),470 ),471 track: MediaTrack::try_from(unsigned[9])?,472 group_id: u64::from_be_bytes(unsigned[22..30].try_into().expect("fixed auth header")),473 object_id: u32::from_be_bytes(unsigned[30..34].try_into().expect("fixed auth header")),474 flags: u16::from_be_bytes(unsigned[10..12].try_into().expect("fixed auth header")),475 };476 let payload_len =477 u32::from_be_bytes(unsigned[34..38].try_into().expect("fixed auth header")) as usize;478 coordinates.validate(payload_len)?;479 Ok(coordinates)480 }481482 pub fn verify<'a>(483 object: &'a [u8],484 certificate: &SignedMediaGenerationCertificate,485 expected: MediaObjectCoordinates,486 ) -> Result<&'a [u8]> {487 certificate.verify()?;488 let actual = Self::coordinates(object)?;489 let unsigned = &object[..OBJECT_UNSIGNED_HEADER_LEN];490 let payload_len =491 u32::from_be_bytes(unsigned[34..38].try_into().expect("fixed auth header")) as usize;492 if actual != expected || actual.generation_id != certificate.claims.generation_id {493 return invalid(494 "authenticated media object",495 "object coordinates do not match delivery coordinates",496 );497 }498 let payload = &object[AUTHENTICATED_MEDIA_OBJECT_HEADER_LEN..];499 if payload.len() != payload_len {500 return invalid(501 "authenticated media object",502 "payload length does not match authentication header",503 );504 }505 let expected_digest: [u8; 32] = unsigned[38..70].try_into().expect("fixed auth digest");506 let actual_digest: [u8; 32] = Sha256::digest(payload).into();507 if actual_digest != expected_digest {508 return invalid(509 "authenticated media object",510 "payload digest verification failed",511 );512 }513 let key =514 VerifyingKey::from_bytes(&certificate.claims.generation_signing_key).map_err(|_| {515 ProtocolError::InvalidMessage {516 message_type: "authenticated media object",517 reason: "certificate generation key is invalid",518 }519 })?;520 let mut message = Vec::with_capacity(OBJECT_DOMAIN.len() + unsigned.len());521 message.extend_from_slice(OBJECT_DOMAIN);522 message.extend_from_slice(unsigned);523 let signature = Signature::from_bytes(524 object[OBJECT_UNSIGNED_HEADER_LEN..AUTHENTICATED_MEDIA_OBJECT_HEADER_LEN]525 .try_into()526 .expect("fixed auth signature"),527 );528 key.verify_strict(&message, &signature)529 .map_err(|_| ProtocolError::InvalidMessage {530 message_type: "authenticated media object",531 reason: "object signature verification failed",532 })?;533 Ok(payload)534 }535}536537fn encode_unsigned_object_header(538 coordinates: MediaObjectCoordinates,539 payload_len: usize,540 digest: [u8; 32],541) -> Result<[u8; OBJECT_UNSIGNED_HEADER_LEN]> {542 coordinates.validate(payload_len)?;543 let mut bytes = [0_u8; OBJECT_UNSIGNED_HEADER_LEN];544 bytes[0..4].copy_from_slice(&OBJECT_MAGIC);545 bytes[4..6].copy_from_slice(&OBJECT_VERSION.to_be_bytes());546 bytes[6..8].copy_from_slice(&(AUTHENTICATED_MEDIA_OBJECT_HEADER_LEN as u16).to_be_bytes());547 bytes[8] = MediaAuthMode::Ed25519 as u8;548 bytes[9] = coordinates.track as u8;549 bytes[10..12].copy_from_slice(&coordinates.flags.to_be_bytes());550 bytes[14..22].copy_from_slice(&coordinates.generation_id.to_be_bytes());551 bytes[22..30].copy_from_slice(&coordinates.group_id.to_be_bytes());552 bytes[30..34].copy_from_slice(&coordinates.object_id.to_be_bytes());553 bytes[34..38].copy_from_slice(&(payload_len as u32).to_be_bytes());554 bytes[38..70].copy_from_slice(&digest);555 Ok(bytes)556}557558fn invalid<T>(message_type: &'static str, reason: &'static str) -> Result<T> {559 Err(ProtocolError::InvalidMessage {560 message_type,561 reason,562 })563}564565#[cfg(test)]566mod tests {567 use super::*;568569 fn fixture() -> (MediaGenerationSigningKey, SignedMediaGenerationCertificate) {570 let host = SigningKey::from_bytes(&[7; 32]);571 let generation = MediaGenerationSigningKey::from_bytes(&[9; 32]);572 let certificate = generation573 .certify(574 host.verifying_key().to_bytes(),575 &[7; 32],576 42,577 1_700_000_000,578 1_700_000_600,579 )580 .unwrap();581 (generation, certificate)582 }583584 #[test]585 fn generation_certificate_is_canonical_and_host_bound() {586 let (_, certificate) = fixture();587 let token = certificate.encode();588 assert!(token.starts_with(MEDIA_GENERATION_CERTIFICATE_TOKEN_PREFIX));589 assert_eq!(590 SignedMediaGenerationCertificate::decode(&token).unwrap(),591 certificate592 );593 certificate594 .verify_binding(certificate.claims.host_node_id, 42, 1_700_000_300)595 .unwrap();596 assert!(597 certificate598 .verify_binding([1; 32], 42, 1_700_000_300)599 .is_err()600 );601 assert!(602 certificate603 .verify_binding(certificate.claims.host_node_id, 43, 1_700_000_300)604 .is_err()605 );606 assert!(607 certificate608 .verify_binding(certificate.claims.host_node_id, 42, 1_700_001_000)609 .is_err()610 );611 }612613 #[test]614 fn authenticated_object_header_has_stable_golden_prefix_and_round_trips() {615 let (generation, certificate) = fixture();616 let coordinates = MediaObjectCoordinates {617 generation_id: 42,618 track: MediaTrack::VideoH264,619 group_id: 3,620 object_id: 4,621 flags: 5,622 };623 let object = generation.authenticate(coordinates, b"frame").unwrap();624 assert_eq!(625 &object.as_bytes()[..38],626 &[627 0x53, 0x47, 0x4d, 0x41, 0, 1, 0, 134, 1, 1, 0, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 42, 0,628 0, 0, 0, 0, 0, 0, 3, 0, 0, 0, 4, 0, 0, 0, 5,629 ]630 );631 assert_eq!(632 AuthenticatedMediaObject::verify(object.as_bytes(), &certificate, coordinates).unwrap(),633 b"frame"634 );635 }636637 #[test]638 fn tampering_and_wrong_coordinates_fail_closed() {639 let (generation, certificate) = fixture();640 let coordinates = MediaObjectCoordinates {641 generation_id: 42,642 track: MediaTrack::VideoH264,643 group_id: 3,644 object_id: 4,645 flags: 0,646 };647 let object = generation648 .authenticate(coordinates, b"frame")649 .unwrap()650 .into_bytes();651 let mut payload_tampered = object.clone();652 *payload_tampered.last_mut().unwrap() ^= 1;653 assert!(654 AuthenticatedMediaObject::verify(&payload_tampered, &certificate, coordinates).is_err()655 );656 let mut signature_tampered = object.clone();657 signature_tampered[OBJECT_UNSIGNED_HEADER_LEN] ^= 1;658 assert!(659 AuthenticatedMediaObject::verify(&signature_tampered, &certificate, coordinates)660 .is_err()661 );662 let wrong = MediaObjectCoordinates {663 object_id: 5,664 ..coordinates665 };666 assert!(AuthenticatedMediaObject::verify(&object, &certificate, wrong).is_err());667 }668669 #[test]670 fn malformed_lengths_versions_modes_and_reserved_fields_fail_closed() {671 let (generation, certificate) = fixture();672 let coordinates = MediaObjectCoordinates {673 generation_id: 42,674 track: MediaTrack::VideoH264,675 group_id: 0,676 object_id: 0,677 flags: 0,678 };679 let valid = generation680 .authenticate(coordinates, b"frame")681 .unwrap()682 .into_bytes();683 for offset in [0, 5, 7, 8, 9, 12] {684 let mut malformed = valid.clone();685 malformed[offset] ^= 0xff;686 assert!(687 AuthenticatedMediaObject::verify(&malformed, &certificate, coordinates).is_err(),688 "accepted malformed offset {offset}"689 );690 }691 assert!(692 AuthenticatedMediaObject::verify(&valid[..100], &certificate, coordinates).is_err()693 );694 }695}